diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-01-17 17:10:07 +0100 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-01-18 19:48:46 +0100 |
| commit | 5996c4afce11249804d24f1caa3a99b390543c4d (patch) | |
| tree | d84b98428d95c801492b1354571e2ab3fc0cc99b | |
| parent | dfa78590c2899c7e74b142ebbced4140a354aed0 (diff) | |
Complete LinkedChildren integration and batch DTO optimizations
This commit integrates remaining performance changes:
- Add batch user data fetching in DtoService to reduce N+1 queries
- Add GetNextUpEpisodesBatch in TVSeriesManager for efficient batch retrieval
- Update Video/Movie/BoxSet to use LibraryManager for alternate versions
- Transition LinkedChild to use ItemId instead of Path (obsolete Path/LibraryItemId)
- Update providers and controllers for LinkedChildren-based references
- Add NextUpEpisodeBatchResult for batched episode queries
- Integrate IDescendantQueryProvider in SqliteDatabaseProvider
35 files changed, 2242 insertions, 901 deletions
diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index a320a774c6..0ede5665f9 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -272,7 +272,7 @@ namespace Emby.Server.Implementations.Collections { var childItem = _libraryManager.GetItemById(guidId); - var child = collection.LinkedChildren.FirstOrDefault(i => (i.ItemId.HasValue && i.ItemId.Value.Equals(guidId)) || (childItem is not null && string.Equals(childItem.Path, i.Path, StringComparison.OrdinalIgnoreCase))); + var child = collection.LinkedChildren.FirstOrDefault(i => i.ItemId.HasValue && i.ItemId.Value.Equals(guidId)); if (child is null) { @@ -342,7 +342,7 @@ namespace Emby.Server.Implementations.Collections // this is kind of a performance hack because only Video has alternate versions that should be in a box set? if (item is Video video) { - foreach (var childId in video.GetLocalAlternateVersionIds()) + foreach (var childId in _libraryManager.GetLocalAlternateVersionIds(video)) { if (!results.ContainsKey(childId)) { diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index c5dc3b054c..236b3fabe4 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -153,17 +153,42 @@ namespace Emby.Server.Implementations.Dto private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; /// <inheritdoc /> - public IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null) + public IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null, bool skipVisibilityCheck = false) { - var accessibleItems = user is null ? items : items.Where(x => x.IsVisible(user)).ToList(); + var accessibleItems = skipVisibilityCheck || user is null ? items : items.Where(x => x.IsVisible(user)).ToList(); var returnItems = new BaseItemDto[accessibleItems.Count]; List<(BaseItem, BaseItemDto)>? programTuples = null; List<(BaseItemDto, LiveTvChannel)>? channelTuples = null; + // Batch-fetch user data for all items + Dictionary<Guid, UserItemData>? userDataBatch = null; + if (user is not null && options.EnableUserData) + { + userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user); + } + + // Pre-compute collection folders once to avoid N+1 queries in CanDelete + List<Folder>? allCollectionFolders = null; + if (user is not null && options.ContainsField(ItemFields.CanDelete)) + { + allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); + } + + // Batch-fetch child counts for all folders to avoid N+1 queries + Dictionary<Guid, int>? childCountBatch = null; + if (options.ContainsField(ItemFields.ChildCount)) + { + var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList(); + if (folderIds.Count > 0) + { + childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user?.Id); + } + } + for (int index = 0; index < accessibleItems.Count; index++) { var item = accessibleItems[index]; - var dto = GetBaseItemDtoInternal(item, options, user, owner); + var dto = GetBaseItemDtoInternal(item, options, user, owner, userDataBatch?.GetValueOrDefault(item.Id), allCollectionFolders, childCountBatch); if (item is LiveTvChannel tvChannel) { @@ -197,7 +222,7 @@ namespace Emby.Server.Implementations.Dto public BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null) { - var dto = GetBaseItemDtoInternal(item, options, user, owner); + var dto = GetBaseItemDtoInternal(item, options, user, owner, null); if (item is LiveTvChannel tvChannel) { LivetvManager.AddChannelInfo(new[] { (dto, tvChannel) }, options, user); @@ -215,7 +240,7 @@ namespace Emby.Server.Implementations.Dto return dto; } - private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null) + private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null, UserItemData? userData = null, List<Folder>? allCollectionFolders = null, Dictionary<Guid, int>? childCountBatch = null) { var dto = new BaseItemDto { @@ -252,7 +277,7 @@ namespace Emby.Server.Implementations.Dto if (user is not null) { - AttachUserSpecificInfo(dto, item, user, options); + AttachUserSpecificInfo(dto, item, user, options, userData, childCountBatch); } if (item is IHasMediaSources @@ -274,7 +299,9 @@ namespace Emby.Server.Implementations.Dto { dto.CanDelete = user is null ? item.CanDelete() - : item.CanDelete(user); + : allCollectionFolders is not null + ? item.CanDelete(user, allCollectionFolders) + : item.CanDelete(user); } if (options.ContainsField(ItemFields.CanDownload)) @@ -458,7 +485,7 @@ namespace Emby.Server.Implementations.Dto /// <summary> /// Attaches the user specific info. /// </summary> - private void AttachUserSpecificInfo(BaseItemDto dto, BaseItem item, User user, DtoOptions options) + private void AttachUserSpecificInfo(BaseItemDto dto, BaseItem item, User user, DtoOptions options, UserItemData? userData = null, Dictionary<Guid, int>? childCountBatch = null) { if (item.IsFolder) { @@ -466,7 +493,17 @@ namespace Emby.Server.Implementations.Dto if (options.EnableUserData) { - dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options); + if (userData is not null) + { + // Use pre-fetched user data + dto.UserData = GetUserItemDataDto(userData, item.Id); + item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options); + } + else + { + // Fall back to individual fetch + dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options); + } } if (!dto.ChildCount.HasValue && item.SourceType == SourceType.Library) @@ -485,7 +522,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.ChildCount)) { - dto.ChildCount ??= GetChildCount(folder, user); + dto.ChildCount ??= GetChildCount(folder, user, childCountBatch); } } @@ -503,7 +540,17 @@ namespace Emby.Server.Implementations.Dto { if (options.EnableUserData) { - dto.UserData = _userDataRepository.GetUserDataDto(item, user); + if (userData is not null) + { + // Use pre-fetched user data + dto.UserData = GetUserItemDataDto(userData, item.Id); + item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options); + } + else + { + // Fall back to individual fetch + dto.UserData = _userDataRepository.GetUserDataDto(item, user); + } } } @@ -513,7 +560,25 @@ namespace Emby.Server.Implementations.Dto } } - private static int GetChildCount(Folder folder, User user) + private static UserItemDataDto GetUserItemDataDto(UserItemData data, Guid itemId) + { + ArgumentNullException.ThrowIfNull(data); + + return new UserItemDataDto + { + IsFavorite = data.IsFavorite, + Likes = data.Likes, + PlaybackPositionTicks = data.PlaybackPositionTicks, + PlayCount = data.PlayCount, + Rating = data.Rating, + Played = data.Played, + LastPlayedDate = data.LastPlayedDate, + ItemId = itemId, + Key = data.Key + }; + } + + private static int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch) { // Right now this is too slow to calculate for top level folders on a per-user basis // Just return something so that apps that are expecting a value won't think the folders are empty @@ -522,6 +587,13 @@ namespace Emby.Server.Implementations.Dto return Random.Shared.Next(1, 10); } + // Use pre-fetched batch data if available + if (childCountBatch is not null && childCountBatch.TryGetValue(folder.Id, out var count)) + { + return count; + } + + // Fall back to individual query for special cases (Series, Season, etc.) return folder.GetChildCount(user); } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index f7f5c387e1..2acfd68c36 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -406,6 +406,37 @@ namespace Emby.Server.Implementations.Library item.Id); } + // If deleting a primary version video, clear PrimaryVersionId from alternate versions + if (item is Video video && string.IsNullOrEmpty(video.PrimaryVersionId)) + { + var alternateVersions = GetLocalAlternateVersionIds(video) + .Concat(GetLinkedAlternateVersions(video).Select(v => v.Id)) + .Distinct() + .Select(id => GetItemById(id)) + .OfType<Video>() + .ToList(); + + if (alternateVersions.Count > 0) + { + _logger.LogInformation( + "Clearing PrimaryVersionId from {Count} alternate versions of {Name}", + alternateVersions.Count, + item.Name ?? "Unknown name"); + + // Promote the first alternate version to be the new primary + var newPrimary = alternateVersions[0]; + newPrimary.SetPrimaryVersionId(null); + newPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + + // Update remaining alternates to point to new primary + foreach (var alternate in alternateVersions.Skip(1)) + { + alternate.SetPrimaryVersionId(newPrimary.Id.ToString("N", CultureInfo.InvariantCulture)); + alternate.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + } + } + } + var children = item.IsFolder ? ((Folder)item).GetRecursiveChildren(false) : []; @@ -576,6 +607,9 @@ namespace Emby.Server.Implementations.Library // Trickplay list.Add(_pathManager.GetTrickplayDirectory(video)); + // Chapter Images + list.Add(_pathManager.GetChapterImageFolderPath(video)); + // Subtitles and attachments foreach (var mediaSource in item.GetMediaSources(false)) { @@ -1421,14 +1455,7 @@ namespace Emby.Server.Implementations.Library AddUserToQuery(query, query.User, allowExternalContent); } - var itemList = _itemRepository.GetItemList(query); - var user = query.User; - if (user is not null) - { - return itemList.Where(i => i.IsVisible(user)).ToList(); - } - - return itemList; + return _itemRepository.GetItemList(query); } public IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery query) @@ -1474,6 +1501,11 @@ namespace Emby.Server.Implementations.Library return _itemRepository.GetItemCounts(query); } + public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId) + { + return _itemRepository.GetChildCountBatch(parentIds, userId); + } + public IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery query, List<BaseItem> parents) { SetTopParentIdsOrAncestors(query, parents); @@ -1519,6 +1551,16 @@ namespace Emby.Server.Implementations.Library return _itemRepository.GetNextUpSeriesKeys(query, dateCutoff); } + /// <inheritdoc /> + public IReadOnlyDictionary<string, MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery query, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching) + { + return _itemRepository.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeWatchedForRewatching); + } + public QueryResult<BaseItem> QueryItems(InternalItemsQuery query) { if (query.User is not null) @@ -1700,6 +1742,11 @@ namespace Emby.Server.Implementations.Library private void AddUserToQuery(InternalItemsQuery query, User user, bool allowExternalContent = true) { + if (query.User is null) + { + query.SetUser(user); + } + if (query.AncestorIds.Length == 0 && query.ParentId.IsEmpty() && query.ChannelIds.Count == 0 && @@ -1725,6 +1772,15 @@ namespace Emby.Server.Implementations.Library } } + /// <inheritdoc/> + public void ConfigureUserAccess(InternalItemsQuery query, User user) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(user); + + AddUserToQuery(query, user); + } + private IEnumerable<Guid> GetTopParentIdsForQuery(BaseItem item, User? user) { if (item is UserView view) @@ -1890,6 +1946,38 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> + public IEnumerable<Guid> GetLocalAlternateVersionIds(Video video) + { + ArgumentNullException.ThrowIfNull(video); + + var linkedIds = _itemRepository.GetLinkedChildrenIds(video.Id, (int)MediaBrowser.Controller.Entities.LinkedChildType.LocalAlternateVersion); + if (linkedIds.Count > 0) + { + return linkedIds; + } + + return []; + } + + /// <inheritdoc /> + public IEnumerable<Video> GetLinkedAlternateVersions(Video video) + { + ArgumentNullException.ThrowIfNull(video); + + var linkedIds = _itemRepository.GetLinkedChildrenIds(video.Id, (int)MediaBrowser.Controller.Entities.LinkedChildType.LinkedAlternateVersion); + if (linkedIds.Count > 0) + { + return linkedIds + .Select(id => GetItemById(id)) + .Where(i => i is not null) + .OfType<Video>() + .OrderBy(i => i.SortName); + } + + return []; + } + + /// <inheritdoc /> public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder) { IOrderedEnumerable<BaseItem>? orderedItems = null; @@ -2896,10 +2984,17 @@ namespace Emby.Server.Implementations.Library extra.ExtraType = extraType; } - extra.ParentId = Guid.Empty; - extra.OwnerId = owner.Id; - extra.IsInMixedFolder = isInMixedFolder; - return extra; + // Only set OwnerId if this is actually an extra (not Unknown or null) + if (extra.ExtraType is not null) + { + extra.ParentId = Guid.Empty; + extra.OwnerId = owner.Id; + extra.IsInMixedFolder = isInMixedFolder; + + return extra; + } + + return null; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs index 7f68f7701e..1c2038d839 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -11,7 +11,6 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -27,6 +26,7 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask private readonly IPlaylistManager _playlistManager; private readonly ILogger<CleanupCollectionAndPlaylistPathsTask> _logger; private readonly IProviderManager _providerManager; + private readonly ILibraryManager _libraryManager; /// <summary> /// Initializes a new instance of the <see cref="CleanupCollectionAndPlaylistPathsTask"/> class. @@ -36,18 +36,21 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> public CleanupCollectionAndPlaylistPathsTask( ILocalizationManager localization, ICollectionManager collectionManager, IPlaylistManager playlistManager, ILogger<CleanupCollectionAndPlaylistPathsTask> logger, - IProviderManager providerManager) + IProviderManager providerManager, + ILibraryManager libraryManager) { _localization = localization; _collectionManager = collectionManager; _playlistManager = playlistManager; _logger = logger; _providerManager = providerManager; + _libraryManager = libraryManager; } /// <inheritdoc /> @@ -111,12 +114,15 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask List<LinkedChild>? itemsToRemove = null; foreach (var linkedChild in folder.LinkedChildren) { - var path = linkedChild.Path; - if (!File.Exists(path) && !Directory.Exists(path)) + if (linkedChild.ItemId.HasValue + && !linkedChild.ItemId.Value.IsEmpty() + && _libraryManager.GetItemById(linkedChild.ItemId.Value) is not null) { - _logger.LogInformation("Item in {FolderName} cannot be found at {ItemPath}", folder.Name, path); - (itemsToRemove ??= new List<LinkedChild>()).Add(linkedChild); + continue; } + + _logger.LogInformation("Item in {FolderName} with ItemId {ItemId} no longer exists in library", folder.Name, linkedChild.ItemId); + (itemsToRemove ??= []).Add(linkedChild); } if (itemsToRemove is not null) diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index cf2ca047cf..6f576146e4 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1820,7 +1820,6 @@ namespace Emby.Server.Implementations.Session fields.Remove(ItemFields.Settings); fields.Remove(ItemFields.SortName); fields.Remove(ItemFields.Tags); - fields.Remove(ItemFields.ExtraIds); dtoOptions.Fields = fields.ToArray(); diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index cd98dbe86e..ebabb4ca2f 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -47,7 +47,7 @@ namespace Emby.Server.Implementations.TV if (!string.IsNullOrEmpty(presentationUniqueKey)) { - return GetResult(GetNextUpEpisodes(query, user, new[] { presentationUniqueKey }, options), query); + return GetNextUpBatched(query, user, [presentationUniqueKey], options); } BaseItem[] parents; @@ -58,11 +58,11 @@ namespace Emby.Server.Implementations.TV if (parent is not null) { - parents = new[] { parent }; + parents = [parent]; } else { - parents = Array.Empty<BaseItem>(); + parents = []; } } else @@ -93,7 +93,7 @@ namespace Emby.Server.Implementations.TV if (!string.IsNullOrEmpty(presentationUniqueKey)) { - return GetResult(GetNextUpEpisodes(request, user, [presentationUniqueKey], options), request); + return GetNextUpBatched(request, user, [presentationUniqueKey], options); } if (limit.HasValue) @@ -103,151 +103,138 @@ namespace Emby.Server.Implementations.TV var nextUpSeriesKeys = _libraryManager.GetNextUpSeriesKeys(new InternalItemsQuery(user) { Limit = limit }, parentsFolders, request.NextUpDateCutoff); - var episodes = GetNextUpEpisodes(request, user, nextUpSeriesKeys, options); - - return GetResult(episodes, request); + return GetNextUpBatched(request, user, nextUpSeriesKeys, options); } - private IEnumerable<Episode> GetNextUpEpisodes(NextUpQuery request, User user, IReadOnlyList<string> seriesKeys, DtoOptions dtoOptions) + private QueryResult<BaseItem> GetNextUpBatched(NextUpQuery request, User user, IReadOnlyList<string> seriesKeys, DtoOptions dtoOptions) { - var allNextUp = seriesKeys.Select(i => GetNextUp(i, user, dtoOptions, request.EnableResumable, false)); - - if (request.EnableRewatching) + if (seriesKeys.Count == 0) { - allNextUp = allNextUp - .Concat(seriesKeys.Select(i => GetNextUp(i, user, dtoOptions, false, true))) - .OrderByDescending(i => i.LastWatchedDate); + return new QueryResult<BaseItem>(); } - return allNextUp - .Select(i => i.GetEpisodeFunction()) - .Where(i => i is not null)!; - } - - private static string GetUniqueSeriesKey(Series series) - { - return series.GetPresentationUniqueKey(); - } + var includeSpecials = _configurationManager.Configuration.DisplaySpecialsWithinSeasons; + var includeRewatching = request.EnableRewatching; - /// <summary> - /// Gets the next up. - /// </summary> - /// <returns>Task{Episode}.</returns> - private (DateTime LastWatchedDate, Func<Episode?> GetEpisodeFunction) GetNextUp(string seriesKey, User user, DtoOptions dtoOptions, bool includeResumable, bool includePlayed) - { - var lastQuery = new InternalItemsQuery(user) + var query = new InternalItemsQuery(user) { - AncestorWithPresentationUniqueKey = null, - SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = [BaseItemKind.Episode], - IsPlayed = true, - Limit = 1, - ParentIndexNumberNotEquals = 0, - DtoOptions = new DtoOptions - { - Fields = [ItemFields.SortName], - EnableImages = false - } + DtoOptions = dtoOptions }; - // If including played results, sort first by date played and then by season and episode numbers - lastQuery.OrderBy = includePlayed - ? new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Descending) } - : new[] { (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Descending) }; + var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatching); - var lastWatchedEpisode = _libraryManager.GetItemList(lastQuery).Cast<Episode>().FirstOrDefault(); + var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>(); - Episode? GetEpisode() + foreach (var seriesKey in seriesKeys) { - var nextQuery = new InternalItemsQuery(user) + if (!batchResult.TryGetValue(seriesKey, out var result)) { - AncestorWithPresentationUniqueKey = null, - SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = [BaseItemKind.Episode], - OrderBy = [(ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending)], - Limit = 1, - IsPlayed = includePlayed, - IsVirtualItem = false, - ParentIndexNumberNotEquals = 0, - DtoOptions = dtoOptions - }; - - // Locate the next up episode based on the last watched episode's season and episode number - var lastWatchedParentIndexNumber = lastWatchedEpisode?.ParentIndexNumber; - var lastWatchedIndexNumber = lastWatchedEpisode?.IndexNumberEnd ?? lastWatchedEpisode?.IndexNumber; - if (lastWatchedParentIndexNumber.HasValue && lastWatchedIndexNumber.HasValue) - { - nextQuery.MinParentAndIndexNumber = (lastWatchedParentIndexNumber.Value, lastWatchedIndexNumber.Value + 1); + continue; } - var nextEpisode = _libraryManager.GetItemList(nextQuery).Cast<Episode>().FirstOrDefault(); + var nextEpisode = DetermineNextEpisode(result, user, includeSpecials, request.EnableResumable, false); - if (_configurationManager.Configuration.DisplaySpecialsWithinSeasons) + if (nextEpisode is not null) { - var consideredEpisodes = _libraryManager.GetItemList(new InternalItemsQuery(user) + DateTime lastWatchedDate = DateTime.MinValue; + if (result.LastWatched is not null) { - AncestorWithPresentationUniqueKey = null, - SeriesPresentationUniqueKey = seriesKey, - ParentIndexNumber = 0, - IncludeItemTypes = [BaseItemKind.Episode], - IsPlayed = includePlayed, - IsVirtualItem = false, - DtoOptions = dtoOptions - }) - .Cast<Episode>() - .Where(episode => episode.AirsBeforeSeasonNumber is not null || episode.AirsAfterSeasonNumber is not null) - .ToList(); - - if (lastWatchedEpisode is not null) - { - // Last watched episode is added, because there could be specials that aired before the last watched episode - consideredEpisodes.Add(lastWatchedEpisode); + var userData = _userDataManager.GetUserData(user, result.LastWatched); + lastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1); } - if (nextEpisode is not null) - { - consideredEpisodes.Add(nextEpisode); - } + nextUpList.Add((lastWatchedDate, nextEpisode)); + } - var sortedConsideredEpisodes = _libraryManager.Sort(consideredEpisodes, user, [(ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending)]) - .Cast<Episode>(); - if (lastWatchedEpisode is not null) + if (includeRewatching) + { + var nextPlayedEpisode = DetermineNextEpisodeForRewatching(result, user, includeSpecials); + + if (nextPlayedEpisode is not null) { - sortedConsideredEpisodes = sortedConsideredEpisodes.SkipWhile(episode => !episode.Id.Equals(lastWatchedEpisode.Id)).Skip(1); + DateTime rewatchLastWatchedDate = DateTime.MinValue; + if (result.LastWatchedForRewatching is not null) + { + var userData = _userDataManager.GetUserData(user, result.LastWatchedForRewatching); + rewatchLastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1); + } + + nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode)); } + } + } - nextEpisode = sortedConsideredEpisodes.FirstOrDefault(); + var sortedEpisodes = nextUpList + .OrderByDescending(x => x.LastWatchedDate) + .Select(x => (BaseItem)x.Episode); + + return GetResult(sortedEpisodes, request); + } + + private Episode? DetermineNextEpisode( + MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result, + User user, + bool includeSpecials, + bool includeResumable, + bool includePlayed) + { + var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode; + var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode; + + if (includeSpecials && result.Specials?.Count > 0) + { + var consideredEpisodes = result.Specials + .Cast<Episode>() + .Where(episode => episode.AirsBeforeSeasonNumber is not null || episode.AirsAfterSeasonNumber is not null) + .ToList(); + + if (lastWatchedEpisode is not null) + { + consideredEpisodes.Add(lastWatchedEpisode); } - if (nextEpisode is not null && !includeResumable) + if (nextEpisode is not null) { - var userData = _userDataManager.GetUserData(user, nextEpisode); + consideredEpisodes.Add(nextEpisode); + } - if (userData?.PlaybackPositionTicks > 0) + if (consideredEpisodes.Count > 0) + { + var sortedEpisodes = _libraryManager.Sort(consideredEpisodes, user, [(ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending)]) + .Cast<Episode>(); + + if (lastWatchedEpisode is not null) { - return null; + sortedEpisodes = sortedEpisodes.SkipWhile(episode => !episode.Id.Equals(lastWatchedEpisode.Id)).Skip(1); } - } - return nextEpisode; + nextEpisode = sortedEpisodes.FirstOrDefault(); + } } - if (lastWatchedEpisode is not null) + if (nextEpisode is not null && !includeResumable) { - var userData = _userDataManager.GetUserData(user, lastWatchedEpisode); - - if (userData is null) + var userData = _userDataManager.GetUserData(user, nextEpisode); + if (userData?.PlaybackPositionTicks > 0) { - return (DateTime.MinValue, GetEpisode); + return null; } + } - var lastWatchedDate = userData.LastPlayedDate ?? DateTime.MinValue.AddDays(1); + return nextEpisode; + } - return (lastWatchedDate, GetEpisode); - } + private Episode? DetermineNextEpisodeForRewatching( + MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result, + User user, + bool includeSpecials) + { + return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true); + } - // Return the first episode - return (DateTime.MinValue, GetEpisode); + private static string GetUniqueSeriesKey(Series series) + { + return series.GetPresentationUniqueKey(); } private static QueryResult<BaseItem> GetResult(IEnumerable<BaseItem> items, NextUpQuery query) diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 558e1c6c80..cb0d449aa4 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -456,19 +456,18 @@ public class LibraryController : BaseJellyfinApiController ? null : _userManager.GetUserById(userId.Value); - var counts = new ItemCounts + var query = new InternalItemsQuery(user) { - AlbumCount = GetCount(BaseItemKind.MusicAlbum, user, isFavorite), - EpisodeCount = GetCount(BaseItemKind.Episode, user, isFavorite), - MovieCount = GetCount(BaseItemKind.Movie, user, isFavorite), - SeriesCount = GetCount(BaseItemKind.Series, user, isFavorite), - SongCount = GetCount(BaseItemKind.Audio, user, isFavorite), - MusicVideoCount = GetCount(BaseItemKind.MusicVideo, user, isFavorite), - BoxSetCount = GetCount(BaseItemKind.BoxSet, user, isFavorite), - BookCount = GetCount(BaseItemKind.Book, user, isFavorite) + Recursive = true, + IsVirtualItem = false, + IsFavorite = isFavorite, + DtoOptions = new DtoOptions(false) + { + EnableImages = false + } }; - return counts; + return _libraryManager.GetItemCounts(query); } /// <summary> @@ -937,24 +936,6 @@ public class LibraryController : BaseJellyfinApiController return result; } - private int GetCount(BaseItemKind itemKind, User? user, bool? isFavorite) - { - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] { itemKind }, - Limit = 0, - Recursive = true, - IsVirtualItem = false, - IsFavorite = isFavorite, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }; - - return _libraryManager.GetItemsResult(query).TotalRecordCount; - } - private BaseItem? TranslateParentItem(BaseItem item, User user) { return item.GetParent() is AggregateFolder diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index ccf8e90632..2237e36b5f 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -157,7 +157,7 @@ public class VideosController : BaseJellyfinApiController return NotFound(); } - foreach (var link in item.GetLinkedAlternateVersions()) + foreach (var link in _libraryManager.GetLinkedAlternateVersions(item)) { link.SetPrimaryVersionId(null); link.LinkedAlternateVersions = Array.Empty<LinkedChild>(); @@ -222,18 +222,18 @@ public class VideosController : BaseJellyfinApiController await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase))) + if (!alternateVersionsOfPrimary.Any(i => i.ItemId.HasValue && i.ItemId.Value.Equals(item.Id))) { alternateVersionsOfPrimary.Add(new LinkedChild { - Path = item.Path, - ItemId = item.Id + ItemId = item.Id, + Type = LinkedChildType.LinkedAlternateVersion }); } foreach (var linkedItem in item.LinkedAlternateVersions) { - if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) + if (linkedItem.ItemId.HasValue && !alternateVersionsOfPrimary.Any(i => i.ItemId.HasValue && i.ItemId.Value.Equals(linkedItem.ItemId.Value))) { alternateVersionsOfPrimary.Add(linkedItem); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 110b6b5faf..bfaaa4b24a 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -1,6 +1,7 @@ #pragma warning disable RS0030 // Do not use banned APIs // Do not enforce that because EFCore cannot deal with cultures well. #pragma warning disable CA1304 // Specify CultureInfo +#pragma warning disable CA1309 // Use ordinal string comparison #pragma warning disable CA1311 // Specify a culture or use an invariant version #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons @@ -19,6 +20,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.MatchCriteria; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.Server.Implementations.Extensions; @@ -31,6 +33,7 @@ using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Playlists; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; @@ -39,6 +42,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; using BaseItemEntity = Jellyfin.Database.Implementations.Entities.BaseItemEntity; +using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; +using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; namespace Jellyfin.Server.Implementations.Item; @@ -69,6 +74,7 @@ public sealed class BaseItemRepository private readonly IItemTypeLookup _itemTypeLookup; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly ILogger<BaseItemRepository> _logger; + private readonly IDescendantQueryProvider _descendantQueryProvider; private static readonly IReadOnlyList<ItemValueType> _getAllArtistsValueTypes = [ItemValueType.Artist, ItemValueType.AlbumArtist]; private static readonly IReadOnlyList<ItemValueType> _getArtistValueTypes = [ItemValueType.Artist]; @@ -77,6 +83,10 @@ public sealed class BaseItemRepository private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre]; private static readonly IReadOnlyList<char> SearchWildcardTerms = ['%', '_', '[', ']', '^']; + private static readonly string ImdbProviderName = MetadataProvider.Imdb.ToString().ToLowerInvariant(); + private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant(); + private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant(); + /// <summary> /// Initializes a new instance of the <see cref="BaseItemRepository"/> class. /// </summary> @@ -85,18 +95,21 @@ public sealed class BaseItemRepository /// <param name="itemTypeLookup">The static type lookup.</param> /// <param name="serverConfigurationManager">The server Configuration manager.</param> /// <param name="logger">System logger.</param> + /// <param name="databaseProvider">The database provider for database-specific operations.</param> public BaseItemRepository( IDbContextFactory<JellyfinDbContext> dbProvider, IServerApplicationHost appHost, IItemTypeLookup itemTypeLookup, IServerConfigurationManager serverConfigurationManager, - ILogger<BaseItemRepository> logger) + ILogger<BaseItemRepository> logger, + IJellyfinDatabaseProvider databaseProvider) { _dbProvider = dbProvider; _appHost = appHost; _itemTypeLookup = itemTypeLookup; _serverConfigurationManager = serverConfigurationManager; _logger = logger; + _descendantQueryProvider = databaseProvider.DescendantQueryProvider; } /// <inheritdoc /> @@ -112,7 +125,23 @@ public sealed class BaseItemRepository var date = (DateTime?)DateTime.UtcNow; - var relatedItems = ids.SelectMany(f => TraverseHirachyDown(f, context)).ToArray(); + var descendantIds = ids.SelectMany(f => _descendantQueryProvider.GetAllDescendantIds(context, f)).ToHashSet(); + foreach (var id in ids) + { + descendantIds.Add(id); + } + + var extraIds = context.BaseItems + .Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value)) + .Select(e => e.Id) + .ToArray(); + + foreach (var extraId in extraIds) + { + descendantIds.Add(extraId); + } + + var relatedItems = descendantIds.ToArray(); // Remove any UserData entries for the placeholder item that would conflict with the UserData // being detached from the item being deleted. This is necessary because, during an update, @@ -140,12 +169,14 @@ public sealed class BaseItemRepository context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete(); context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete(); context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete(); + context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete(); + context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete(); context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); @@ -250,7 +281,7 @@ public sealed class BaseItemRepository public QueryResult<BaseItemDto> GetItems(InternalItemsQuery filter) { ArgumentNullException.ThrowIfNull(filter); - if (!filter.EnableTotalRecordCount || ((filter.Limit ?? 0) == 0 && (filter.StartIndex ?? 0) == 0)) + if (!filter.EnableTotalRecordCount || (!filter.Limit.HasValue && (filter.StartIndex ?? 0) == 0)) { var returnList = GetItemList(filter); return new QueryResult<BaseItemDto>( @@ -301,47 +332,276 @@ public sealed class BaseItemRepository } /// <inheritdoc/> - public IReadOnlyList<BaseItem> GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType) + public IReadOnlyList<BaseItemDto> GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType) { ArgumentNullException.ThrowIfNull(filter); PrepareFilterQuery(filter); - // Early exit if collection type is not tvshows or music - if (collectionType != CollectionType.tvshows && collectionType != CollectionType.music) + // Early exit if collection type is not supported + if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music) { - return Array.Empty<BaseItem>(); + return []; } + var limit = filter.Limit ?? 50; using var context = _dbProvider.CreateDbContext(); - // Subquery to group by SeriesNames/Album and get the max Date Created for each group. - var subquery = PrepareItemQuery(context, filter); - subquery = TranslateQuery(subquery, context, filter); - var subqueryGrouped = subquery.GroupBy(g => collectionType == CollectionType.tvshows ? g.SeriesName : g.Album) - .Select(g => new + var baseQuery = PrepareItemQuery(context, filter); + baseQuery = TranslateQuery(baseQuery, context, filter); + + if (collectionType == CollectionType.tvshows) + { + return GetLatestTvShowItems(context, baseQuery, filter, limit); + } + + // Determine the grouping key selector based on collection type + // Movies: PresentationUniqueKey (groups alternate versions like 4K/1080p) + // Music: Album + Func<BaseItemEntity, string?> groupKeySelector = collectionType switch + { + CollectionType.movies => e => e.PresentationUniqueKey, + _ => e => e.Album + }; + + // EF Core requires compile-time expressions, so we use conditional queries + var topGroupKeys = collectionType switch + { + CollectionType.movies => baseQuery + .Where(e => e.PresentationUniqueKey != null) + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => new { GroupKey = g.Key!, MaxDate = g.Max(e => e.DateCreated) }) + .OrderByDescending(g => g.MaxDate) + .Take(limit) + .Select(g => g.GroupKey) + .ToList(), + _ => baseQuery + .Where(e => e.Album != null) + .GroupBy(e => e.Album) + .Select(g => new { GroupKey = g.Key!, MaxDate = g.Max(e => e.DateCreated) }) + .OrderByDescending(g => g.MaxDate) + .Take(limit) + .Select(g => g.GroupKey) + .ToList() + }; + + if (topGroupKeys.Count == 0) + { + return []; + } + + var itemsQuery = collectionType switch + { + CollectionType.movies => baseQuery.Where(e => e.PresentationUniqueKey != null && topGroupKeys.Contains(e.PresentationUniqueKey)), + _ => baseQuery.Where(e => e.Album != null && topGroupKeys.Contains(e.Album)) + }; + + itemsQuery = itemsQuery.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id); + itemsQuery = ApplyNavigations(itemsQuery, filter).AsSingleQuery(); + + var allItems = itemsQuery.ToList(); + + var latestItems = allItems + .GroupBy(groupKeySelector) + .Select(g => g.First()) + .OrderByDescending(e => e.DateCreated) + .ThenByDescending(e => e.Id) + .ToList(); + + return latestItems + .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToArray()!; + } + + /// <summary> + /// Gets the latest TV show items with smart Season/Series container selection. + /// </summary> + /// <remarks> + /// If multiple episodes were recently added to a single season, returns the Season. + /// If episodes span multiple seasons, returns the Series. + /// If only a single episode was added, returns the Episode. + /// </remarks> + private IReadOnlyList<BaseItemDto> GetLatestTvShowItems(JellyfinDbContext context, IQueryable<BaseItemEntity> baseQuery, InternalItemsQuery filter, int limit) + { + const int WindowDays = 7; + const int MaxWindowDays = 84; + const double RecentAdditionWindowHours = 24.0; + + var now = DateTime.UtcNow; + + List<string> topSeriesNames = []; + for (int days = WindowDays; days <= MaxWindowDays && topSeriesNames.Count < limit; days += WindowDays) + { + var cutoff = now.AddDays(-days); + topSeriesNames = baseQuery + .Where(e => e.SeriesName != null && e.DateCreated >= cutoff) + .GroupBy(e => e.SeriesName) + .OrderByDescending(g => g.Max(e => e.DateCreated)) + .Take(limit) + .Select(g => g.Key!) + .ToList(); + } + + // Fallback without date filter if needed + if (topSeriesNames.Count < limit) + { + topSeriesNames = baseQuery + .Where(e => e.SeriesName != null) + .GroupBy(e => e.SeriesName) + .OrderByDescending(g => g.Max(e => e.DateCreated)) + .Take(limit) + .Select(g => g.Key!) + .ToList(); + } + + if (topSeriesNames.Count == 0) + { + return []; + } + + // Get all episodes from identified series with navigations + var allEpisodes = ApplyNavigations( + baseQuery + .Where(e => e.SeriesName != null && topSeriesNames.Contains(e.SeriesName)) + .OrderByDescending(e => e.DateCreated) + .ThenByDescending(e => e.Id), + filter) + .AsSingleQuery() + .ToList(); + + var allSeasonIds = new HashSet<Guid>(); + var allSeriesIds = new HashSet<Guid>(); + var analysisData = new List<( + List<BaseItemEntity> RecentEpisodes, + List<Guid> SeasonIds, + DateTime MaxDate, + BaseItemEntity MostRecentEpisode)>(); + + foreach (var group in allEpisodes.GroupBy(e => e.SeriesName)) + { + var episodes = group.ToList(); + var mostRecentDate = episodes[0].DateCreated ?? DateTime.MinValue; + var recentCutoff = mostRecentDate.AddHours(-RecentAdditionWindowHours); + + var recentEpisodes = new List<BaseItemEntity>(); + var seasonIdSet = new HashSet<Guid>(); + + foreach (var ep in episodes) { - Key = g.Key, - MaxDateCreated = g.Max(a => a.DateCreated) - }) - .OrderByDescending(g => g.MaxDateCreated) - .Select(g => g); + if (ep.DateCreated >= recentCutoff) + { + recentEpisodes.Add(ep); + if (ep.SeasonId.HasValue) + { + seasonIdSet.Add(ep.SeasonId.Value); + } + } + } + + var seasonIds = seasonIdSet.ToList(); + analysisData.Add((recentEpisodes, seasonIds, mostRecentDate, episodes[0])); + + foreach (var sid in seasonIds) + { + allSeasonIds.Add(sid); + } - if (filter.Limit.HasValue && filter.Limit.Value > 0) + if (recentEpisodes.Count > 0 && recentEpisodes[0].SeriesId.HasValue) + { + allSeriesIds.Add(recentEpisodes[0].SeriesId!.Value); + } + } + + var episodeType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + var seasonType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Season]; + var seasonEpisodeCounts = allSeasonIds.Count > 0 + ? context.BaseItems + .AsNoTracking() + .Where(e => e.SeasonId.HasValue && allSeasonIds.Contains(e.SeasonId.Value) && e.Type == episodeType) + .GroupBy(e => e.SeasonId!.Value) + .Select(g => new { SeasonId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeasonId, x => x.Count) + : []; + + var seriesSeasonCounts = allSeriesIds.Count > 0 + ? context.BaseItems + .AsNoTracking() + .Where(e => e.SeriesId.HasValue && allSeriesIds.Contains(e.SeriesId.Value) && e.Type == seasonType) + .GroupBy(e => e.SeriesId!.Value) + .Select(g => new { SeriesId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeriesId, x => x.Count) + : []; + + var entitiesToFetch = new HashSet<Guid>(); + var seriesResults = new List<(Guid? SeasonId, Guid? SeriesId, DateTime MaxDate, BaseItemEntity MostRecentEpisode)>(analysisData.Count); + foreach (var (recentEpisodes, seasonIds, maxDate, mostRecentEpisode) in analysisData) { - subqueryGrouped = subqueryGrouped.Take(filter.Limit.Value); + Guid? seasonId = null; + Guid? seriesId = null; + + if (seasonIds.Count == 1) + { + var sid = seasonIds[0]; + var totalEpisodes = seasonEpisodeCounts.GetValueOrDefault(sid, 0); + var episodeSeriesId = recentEpisodes.Count > 0 ? recentEpisodes[0].SeriesId : null; + var totalSeasonsInSeries = episodeSeriesId.HasValue + ? seriesSeasonCounts.GetValueOrDefault(episodeSeriesId.Value, 1) + : 1; + + var hasMultipleOrAllEpisodes = recentEpisodes.Count > 1 || recentEpisodes.Count == totalEpisodes; + if (totalSeasonsInSeries > 1 && hasMultipleOrAllEpisodes) + { + seasonId = sid; + entitiesToFetch.Add(sid); + } + else if (hasMultipleOrAllEpisodes && episodeSeriesId.HasValue) + { + seriesId = episodeSeriesId; + entitiesToFetch.Add(episodeSeriesId.Value); + } + } + else if (seasonIds.Count > 1 && recentEpisodes.Count > 0 && recentEpisodes[0].SeriesId.HasValue) + { + seriesId = recentEpisodes[0].SeriesId; + entitiesToFetch.Add(seriesId!.Value); + } + + seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisode)); } - filter.Limit = null; + var entities = entitiesToFetch.Count > 0 + ? ApplyNavigations( + context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)), + filter) + .AsSingleQuery() + .ToDictionary(e => e.Id) + : []; - var mainquery = PrepareItemQuery(context, filter); - mainquery = TranslateQuery(mainquery, context, filter); - mainquery = mainquery.Where(g => g.DateCreated >= subqueryGrouped.Min(s => s.MaxDateCreated)); - mainquery = ApplyGroupingFilter(context, mainquery, filter); - mainquery = ApplyQueryPaging(mainquery, filter); + var results = new List<(BaseItemEntity Entity, DateTime MaxDate)>(seriesResults.Count); + foreach (var (seasonId, seriesId, maxDate, mostRecentEpisode) in seriesResults) + { + if (seasonId.HasValue && entities.TryGetValue(seasonId.Value, out var seasonEntity)) + { + results.Add((seasonEntity, maxDate)); + continue; + } - mainquery = ApplyNavigations(mainquery, filter); + if (seriesId.HasValue && entities.TryGetValue(seriesId.Value, out var seriesEntity)) + { + results.Add((seriesEntity, maxDate)); + continue; + } - return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!; + results.Add((mostRecentEpisode, maxDate)); + } + + return results + .OrderByDescending(r => r.MaxDate) + .ThenByDescending(r => r.Entity.Id) + .Take(limit) + .Select(r => DeserializeBaseItem(r.Entity, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToArray()!; } /// <inheritdoc /> @@ -367,7 +627,7 @@ public sealed class BaseItemRepository .OrderByDescending(g => g.LastPlayedDate) .Select(g => g.Key!); - if (filter.Limit.HasValue && filter.Limit.Value > 0) + if (filter.Limit.HasValue) { query = query.Take(filter.Limit.Value); } @@ -375,6 +635,277 @@ public sealed class BaseItemRepository return query.ToArray(); } + /// <inheritdoc /> + public IReadOnlyDictionary<string, NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery filter, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(filter.User); + + if (seriesKeys.Count == 0) + { + return new Dictionary<string, NextUpEpisodeBatchResult>(); + } + + PrepareFilterQuery(filter); + using var context = _dbProvider.CreateDbContext(); + + var userId = filter.User.Id; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + + // Get the last watched episode ID per series (highest season/episode that is played) + var lastWatchedInfo = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber != 0) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) + .GroupBy(e => e.SeriesPresentationUniqueKey) + .Select(g => new + { + SeriesKey = g.Key!, + LastWatchedId = g.OrderByDescending(e => e.ParentIndexNumber) + .ThenByDescending(e => e.IndexNumber) + .Select(e => e.Id) + .FirstOrDefault() + }) + .ToDictionary(x => x.SeriesKey, x => x.LastWatchedId); + + Dictionary<string, Guid> lastWatchedByDateInfo = new(); + if (includeWatchedForRewatching) + { + lastWatchedByDateInfo = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber != 0) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) + .SelectMany(e => e.UserData!.Where(ud => ud.UserId == userId && ud.Played) + .Select(ud => new { Episode = e, ud.LastPlayedDate })) + .GroupBy(x => x.Episode.SeriesPresentationUniqueKey) + .Select(g => new + { + SeriesKey = g.Key!, + LastWatchedId = g.OrderByDescending(x => x.LastPlayedDate) + .Select(x => x.Episode.Id) + .FirstOrDefault() + }) + .ToDictionary(x => x.SeriesKey, x => x.LastWatchedId); + } + + var allLastWatchedIds = lastWatchedInfo.Values + .Concat(lastWatchedByDateInfo.Values) + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + var lastWatchedEpisodes = new Dictionary<Guid, BaseItemEntity>(); + if (allLastWatchedIds.Count > 0) + { + var lwQuery = context.BaseItems.AsNoTracking().Where(e => allLastWatchedIds.Contains(e.Id)); + lwQuery = ApplyNavigations(lwQuery, filter).AsSingleQuery(); + lastWatchedEpisodes = lwQuery.ToDictionary(e => e.Id); + } + + var allNextUnwatchedCandidates = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber != 0) + .Where(e => !e.IsVirtualItem) + .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) + .Select(e => new + { + e.Id, + e.SeriesPresentationUniqueKey, + e.ParentIndexNumber, + EpisodeNumber = e.IndexNumber + }) + .ToList(); + + List<(Guid Id, string? SeriesKey, int? Season, int? Episode)> allNextPlayedCandidates = new(); + if (includeWatchedForRewatching) + { + allNextPlayedCandidates = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber != 0) + .Where(e => !e.IsVirtualItem) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) + .Select(e => new + { + e.Id, + e.SeriesPresentationUniqueKey, + e.ParentIndexNumber, + EpisodeNumber = e.IndexNumber + }) + .AsEnumerable() + .Select(e => (e.Id, e.SeriesPresentationUniqueKey, e.ParentIndexNumber, e.EpisodeNumber)) + .ToList(); + } + + Dictionary<string, List<BaseItemEntity>> specialsBySeriesKey = new(); + if (includeSpecials) + { + var allSpecials = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber == 0) + .Where(e => !e.IsVirtualItem) + .ToList(); + + var specialIds = allSpecials.Select(s => s.Id).ToList(); + if (specialIds.Count > 0) + { + var specialsWithNav = context.BaseItems.AsNoTracking().Where(e => specialIds.Contains(e.Id)); + specialsWithNav = ApplyNavigations(specialsWithNav, filter).AsSingleQuery(); + var specialsDict = specialsWithNav.ToDictionary(e => e.Id); + + foreach (var special in allSpecials) + { + var key = special.SeriesPresentationUniqueKey!; + if (!specialsBySeriesKey.TryGetValue(key, out var list)) + { + list = new List<BaseItemEntity>(); + specialsBySeriesKey[key] = list; + } + + if (specialsDict.TryGetValue(special.Id, out var specialWithNav)) + { + list.Add(specialWithNav); + } + } + } + } + + var nextEpisodeIds = new HashSet<Guid>(); + var seriesNextIdMap = new Dictionary<string, Guid>(); + var seriesNextPlayedIdMap = new Dictionary<string, Guid>(); + + foreach (var seriesKey in seriesKeys) + { + var candidates = allNextUnwatchedCandidates + .Where(c => c.SeriesPresentationUniqueKey == seriesKey); + + if (lastWatchedInfo.TryGetValue(seriesKey, out var lwId) && lwId != Guid.Empty) + { + var lastWatchedEntity = lastWatchedEpisodes.GetValueOrDefault(lwId); + if (lastWatchedEntity is not null) + { + var season = lastWatchedEntity.ParentIndexNumber; + var episode = lastWatchedEntity.IndexNumber; + if (season.HasValue && episode.HasValue) + { + candidates = candidates.Where(c => + c.ParentIndexNumber > season || + (c.ParentIndexNumber == season && c.EpisodeNumber > episode)); + } + } + } + + var nextCandidate = candidates + .OrderBy(c => c.ParentIndexNumber) + .ThenBy(c => c.EpisodeNumber) + .FirstOrDefault(); + + if (nextCandidate is not null && nextCandidate.Id != Guid.Empty) + { + nextEpisodeIds.Add(nextCandidate.Id); + seriesNextIdMap[seriesKey] = nextCandidate.Id; + } + + if (includeWatchedForRewatching && lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId)) + { + var lastByDateEntity = lastWatchedEpisodes.GetValueOrDefault(lastByDateId); + if (lastByDateEntity is not null) + { + var lastSeason = lastByDateEntity.ParentIndexNumber; + var lastEp = lastByDateEntity.IndexNumber; + + var playedCandidates = allNextPlayedCandidates + .Where(c => c.SeriesKey == seriesKey); + + if (lastSeason.HasValue && lastEp.HasValue) + { + playedCandidates = playedCandidates.Where(c => + c.Season > lastSeason || + (c.Season == lastSeason && c.Episode > lastEp)); + } + + var nextPlayedCandidate = playedCandidates + .OrderBy(c => c.Season) + .ThenBy(c => c.Episode) + .FirstOrDefault(); + + if (nextPlayedCandidate.Id != Guid.Empty) + { + nextEpisodeIds.Add(nextPlayedCandidate.Id); + seriesNextPlayedIdMap[seriesKey] = nextPlayedCandidate.Id; + } + } + } + } + + var nextEpisodes = new Dictionary<Guid, BaseItemEntity>(); + if (nextEpisodeIds.Count > 0) + { + var nextQuery = context.BaseItems.AsNoTracking().Where(e => nextEpisodeIds.Contains(e.Id)); + nextQuery = ApplyNavigations(nextQuery, filter).AsSingleQuery(); + nextEpisodes = nextQuery.ToDictionary(e => e.Id); + } + + var result = new Dictionary<string, NextUpEpisodeBatchResult>(); + foreach (var seriesKey in seriesKeys) + { + var batchResult = new NextUpEpisodeBatchResult(); + + if (lastWatchedInfo.TryGetValue(seriesKey, out var lwId) && lwId != Guid.Empty) + { + if (lastWatchedEpisodes.TryGetValue(lwId, out var entity)) + { + batchResult.LastWatched = DeserializeBaseItem(entity, filter.SkipDeserialization); + } + } + + if (seriesNextIdMap.TryGetValue(seriesKey, out var nextId) && nextEpisodes.TryGetValue(nextId, out var nextEntity)) + { + batchResult.NextUp = DeserializeBaseItem(nextEntity, filter.SkipDeserialization); + } + + if (includeSpecials && specialsBySeriesKey.TryGetValue(seriesKey, out var specials)) + { + batchResult.Specials = specials.Select(s => DeserializeBaseItem(s, filter.SkipDeserialization)!).ToList(); + } + else + { + batchResult.Specials = Array.Empty<BaseItemDto>(); + } + + if (includeWatchedForRewatching) + { + if (lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId) && + lastWatchedEpisodes.TryGetValue(lastByDateId, out var lastByDateEntity)) + { + batchResult.LastWatchedForRewatching = DeserializeBaseItem(lastByDateEntity, filter.SkipDeserialization); + } + + if (seriesNextPlayedIdMap.TryGetValue(seriesKey, out var nextPlayedId) && + nextEpisodes.TryGetValue(nextPlayedId, out var nextPlayedEntity)) + { + batchResult.NextPlayedForRewatching = DeserializeBaseItem(nextPlayedEntity, filter.SkipDeserialization); + } + } + + result[seriesKey] = batchResult; + } + + return result; + } + private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) { // This whole block is needed to filter duplicate entries on request @@ -435,19 +966,44 @@ public sealed class BaseItemRepository dbQuery = dbQuery.Include(e => e.Images); } + // Only include LinkedChildEntities for container types and videos that use them + // (BoxSet, Playlist, CollectionFolder for manual linking; Video, Movie for alternate versions) + var linkedChildTypes = new[] + { + BaseItemKind.BoxSet, + BaseItemKind.Playlist, + BaseItemKind.CollectionFolder, + BaseItemKind.Video, + BaseItemKind.Movie + }; + if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains)) + { + dbQuery = dbQuery.Include(e => e.LinkedChildEntities); + } + + if (filter.IncludeExtras) + { + dbQuery = dbQuery.Include(e => e.Extras); + } + return dbQuery; } private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) { - if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0) + if (filter.Limit.HasValue || filter.StartIndex.HasValue) { - dbQuery = dbQuery.Skip(filter.StartIndex.Value); - } + var offset = filter.StartIndex ?? 0; - if (filter.Limit.HasValue && filter.Limit.Value > 0) - { - dbQuery = dbQuery.Take(filter.Limit.Value); + if (offset > 0) + { + dbQuery = dbQuery.Skip(offset); + } + + if (filter.Limit.HasValue) + { + dbQuery = dbQuery.Take(filter.Limit.Value); + } } return dbQuery; @@ -499,7 +1055,10 @@ public sealed class BaseItemRepository .ToArray(); var lookup = _itemTypeLookup.BaseItemKindNames; - var result = new ItemCounts(); + var result = new ItemCounts + { + ItemCount = counts.Sum(c => c.Count) + }; foreach (var count in counts) { if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) @@ -538,6 +1097,14 @@ public sealed class BaseItemRepository { result.TrailerCount = count.Count; } + else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal)) + { + result.BoxSetCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal)) + { + result.BookCount = count.Count; + } } return result; @@ -561,8 +1128,36 @@ public sealed class BaseItemRepository .FirstOrDefault(t => t is not null)); } + /// <summary> + /// Saves image information for an item. + /// </summary> + /// <param name="item">The item DTO containing image info.</param> + public void SaveImages(BaseItemDto item) + { + ArgumentNullException.ThrowIfNull(item); + + var images = item.ImageInfos.Select(e => Map(item.Id, e)); + using var context = _dbProvider.CreateDbContext(); + + if (!context.BaseItems.Any(bi => bi.Id == item.Id)) + { + _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem"); + return; + } + + context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete(); + context.BaseItemImageInfos.AddRange(images); + context.SaveChanges(); + } + /// <inheritdoc /> - public async Task SaveImagesAsync(BaseItemDto item, CancellationToken cancellationToken = default) + public void SaveItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken) + { + UpdateOrInsertItems(items, cancellationToken); + } + + /// <inheritdoc /> + public async Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(item); @@ -592,12 +1187,6 @@ public sealed class BaseItemRepository } } - /// <inheritdoc /> - public void SaveItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken) - { - UpdateOrInsertItems(items, cancellationToken); - } - /// <inheritdoc cref="IItemRepository"/> public void UpdateOrInsertItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken) { @@ -746,13 +1335,195 @@ public sealed class BaseItemRepository context.AncestorIds.RemoveRange(existingAncestorIds); } + + if (item.Item is Folder folder) + { + var existingLinkedChildren = context.LinkedChildren.Where(e => e.ParentId == item.Item.Id).ToList(); + if (folder.LinkedChildren.Length > 0) + { +#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data + var pathsToResolve = folder.LinkedChildren + .Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path)) + .Select(lc => lc.Path) + .Distinct() + .ToList(); + + var pathToIdMap = pathsToResolve.Count > 0 + ? context.BaseItems + .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) + .Select(e => new { e.Path, e.Id }) + .ToDictionary(e => e.Path!, e => e.Id) + : []; + + var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>(); + foreach (var linkedChild in folder.LinkedChildren) + { + var childItemId = linkedChild.ItemId; + if (!childItemId.HasValue || childItemId.Value.IsEmpty()) + { + if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId)) + { + childItemId = resolvedId; + } + } +#pragma warning restore CS0618 + + if (childItemId.HasValue && !childItemId.Value.IsEmpty()) + { + resolvedChildren.Add((linkedChild, childItemId.Value)); + } + } + + var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList(); + var existingChildIds = childIdsToCheck.Count > 0 + ? context.BaseItems + .Where(e => childIdsToCheck.Contains(e.Id)) + .Select(e => e.Id) + .ToHashSet() + : []; + + var isPlaylist = folder is Playlist; + var sortOrder = 0; + foreach (var (linkedChild, childId) in resolvedChildren) + { + if (!existingChildIds.Contains(childId)) + { + _logger.LogWarning( + "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database", + item.Item.Name, + item.Item.Id, + childId); + continue; + } + + var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId); + if (existingLink is null) + { + context.LinkedChildren.Add(new LinkedChildEntity() + { + ParentId = item.Item.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)linkedChild.Type, + SortOrder = isPlaylist ? sortOrder : null + }); + } + else + { + existingLink.SortOrder = isPlaylist ? sortOrder : null; + existingLink.ChildType = (DbLinkedChildType)linkedChild.Type; + existingLinkedChildren.Remove(existingLink); + } + + sortOrder++; + } + } + + if (existingLinkedChildren.Count > 0) + { + context.LinkedChildren.RemoveRange(existingLinkedChildren); + } + } + + // Handle Video alternate versions + if (item.Item is Video video) + { + var existingLinkedChildren = context.LinkedChildren + .Where(e => e.ParentId == video.Id && ((int)e.ChildType == 2 || (int)e.ChildType == 3)) + .ToList(); + + var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>(); + + // Process LocalAlternateVersions (path-based alternate versions) + if (video.LocalAlternateVersions.Length > 0) + { + var pathsToResolve = video.LocalAlternateVersions.Where(p => !string.IsNullOrEmpty(p)).ToList(); + if (pathsToResolve.Count > 0) + { + var pathToIdMap = context.BaseItems + .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) + .Select(e => new { e.Path, e.Id }) + .ToDictionary(e => e.Path!, e => e.Id); + + foreach (var path in pathsToResolve) + { + if (pathToIdMap.TryGetValue(path, out var childId)) + { + newLinkedChildren.Add((childId, LinkedChildType.LocalAlternateVersion)); + } + } + } + } + + // Process LinkedAlternateVersions (ID-based alternate versions) + if (video.LinkedAlternateVersions.Length > 0) + { + foreach (var linkedChild in video.LinkedAlternateVersions) + { + if (linkedChild.ItemId.HasValue && !linkedChild.ItemId.Value.IsEmpty()) + { + newLinkedChildren.Add((linkedChild.ItemId.Value, LinkedChildType.LinkedAlternateVersion)); + } + } + } + + // Validate that all child items exist + var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).Distinct().ToList(); + var existingChildIds = childIdsToCheck.Count > 0 + ? context.BaseItems + .Where(e => childIdsToCheck.Contains(e.Id)) + .Select(e => e.Id) + .ToHashSet() + : []; + + // Add or update LinkedChildren entries + foreach (var (childId, childType) in newLinkedChildren) + { + if (!existingChildIds.Contains(childId)) + { + _logger.LogWarning( + "Skipping alternate version for video {VideoName} ({VideoId}): child item {ChildId} does not exist in database", + video.Name, + video.Id, + childId); + continue; + } + + var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId); + if (existingLink is null) + { + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = video.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)childType, + SortOrder = null + }); + } + else + { + existingLink.ChildType = (DbLinkedChildType)childType; + existingLinkedChildren.Remove(existingLink); + } + } + + // Remove orphaned alternate version links + if (existingLinkedChildren.Count > 0) + { + context.LinkedChildren.RemoveRange(existingLinkedChildren); + } + } } context.SaveChanges(); transaction.Commit(); } - /// <inheritdoc /> + /// <summary> + /// Reattaches user data entries that were incorrectly associated with a different item. + /// </summary> + /// <param name="item">The item DTO to reattach user data for.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A task representing the asynchronous operation.</returns> public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(item); @@ -795,7 +1566,8 @@ public sealed class BaseItemRepository .Include(e => e.Provider) .Include(e => e.LockedFields) .Include(e => e.UserData) - .Include(e => e.Images); + .Include(e => e.Images) + .Include(e => e.LinkedChildEntities); var item = dbQuery.FirstOrDefault(e => e.Id == id); if (item is null) @@ -958,6 +1730,17 @@ public sealed class BaseItemRepository if (dto is Folder folder) { folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); + if (entity.LinkedChildEntities is not null && entity.LinkedChildEntities.Count > 0) + { + folder.LinkedChildren = entity.LinkedChildEntities + .OrderBy(e => e.SortOrder) + .Select(e => new LinkedChild + { + ItemId = e.ChildId, + Type = (LinkedChildType)e.ChildType + }) + .ToArray(); + } } return dto; @@ -1143,7 +1926,7 @@ public sealed class BaseItemRepository var query = context.ItemValuesMap .AsNoTracking() - .Where(e => itemValueTypes.Any(w => (ItemValueType)w == e.ItemValue.Type)); + .Where(e => itemValueTypes.Any(w => w == e.ItemValue.Type)); if (withItemTypes.Count > 0) { query = query.Where(e => withItemTypes.Contains(e.Item.Type)); @@ -1227,7 +2010,7 @@ public sealed class BaseItemRepository { ArgumentNullException.ThrowIfNull(filter); - if (!(filter.Limit.HasValue && filter.Limit.Value > 0)) + if (!filter.Limit.HasValue) { filter.EnableTotalRecordCount = false; } @@ -1250,15 +2033,13 @@ public sealed class BaseItemRepository IsNews = filter.IsNews, IsSeries = filter.IsSeries }); - - var itemValuesQuery = context.ItemValues - .Where(f => itemValueTypes.Contains(f.Type)) - .SelectMany(f => f.BaseItemsMap!, (f, w) => new { f, w }) + var itemValuesQuery = context.ItemValuesMap + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) .Join( innerQueryFilter, - fw => fw.w.ItemId, + ivm => ivm.ItemId, g => g.Id, - (fw, g) => fw.f.CleanValue); + (ivm, g) => ivm.ItemValue.CleanValue); var innerQuery = PrepareItemQuery(context, filter) .Where(e => e.Type == returnType) @@ -1295,6 +2076,7 @@ public sealed class BaseItemRepository .Include(e => e.Provider) .Include(e => e.LockedFields) .Include(e => e.Images) + .Include(e => e.LinkedChildEntities) .AsSingleQuery() .Where(e => masterQuery.Contains(e.Id)); @@ -1306,22 +2088,23 @@ public sealed class BaseItemRepository result.TotalRecordCount = query.Count(); } - if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0) + if (filter.Limit.HasValue || filter.StartIndex.HasValue) { - query = query.Skip(filter.StartIndex.Value); - } + var offset = filter.StartIndex ?? 0; - if (filter.Limit.HasValue && filter.Limit.Value > 0) - { - query = query.Take(filter.Limit.Value); - } + if (offset > 0) + { + query = query.Skip(offset); + } - IQueryable<BaseItemEntity>? itemCountQuery = null; + if (filter.Limit.HasValue) + { + query = query.Take(filter.Limit.Value); + } + } if (filter.IncludeItemTypes.Length > 0) { - // if we are to include more then one type, sub query those items beforehand. - var typeSubQuery = new InternalItemsQuery(filter.User) { ExcludeItemTypes = filter.ExcludeItemTypes, @@ -1335,7 +2118,7 @@ public sealed class BaseItemRepository IsPlayed = filter.IsPlayed }; - itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) + var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; @@ -1346,31 +2129,49 @@ public sealed class BaseItemRepository var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; - var resultQuery = query.Select(e => new - { - item = e, - // TODO: This is bad refactor! - itemCount = new ItemCounts() - { - SeriesCount = itemCountQuery!.Count(f => f.Type == seriesTypeName), - EpisodeCount = itemCountQuery!.Count(f => f.Type == episodeTypeName), - MovieCount = itemCountQuery!.Count(f => f.Type == movieTypeName), - AlbumCount = itemCountQuery!.Count(f => f.Type == musicAlbumTypeName), - ArtistCount = itemCountQuery!.Count(f => f.Type == musicArtistTypeName), - SongCount = itemCountQuery!.Count(f => f.Type == audioTypeName), - TrailerCount = itemCountQuery!.Count(f => f.Type == trailerTypeName), - } - }); + // Get the IDs from itemCountQuery to use in the join + var itemIds = itemCountQuery.Select(e => e.Id); + + // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) + // Instead, start from ItemValueMaps and join with BaseItems + var countsByCleanName = context.ItemValuesMap + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) + .Where(ivm => itemIds.Contains(ivm.ItemId)) + .Join( + context.BaseItems, + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type }) + .GroupBy(x => new { x.CleanName, x.Type }) + .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() }) + .GroupBy(x => x.CleanName) + .ToDictionary( + g => g.Key, + g => new ItemCounts + { + SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count), + EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count), + MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count), + AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count), + ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count), + SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count), + TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count), + }); result.StartIndex = filter.StartIndex ?? 0; result.Items = [ - .. resultQuery + .. query .AsEnumerable() .Where(e => e is not null) - .Select(e => (Item: DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount)) - .Where(e => e.Item is not null) - .Select(e => (e.Item!, e.itemCount)) + .Select(e => + { + var item = DeserializeBaseItem(e, filter.SkipDeserialization); + countsByCleanName.TryGetValue(e.CleanName ?? string.Empty, out var itemCount); + return (item, itemCount); + }) + .Where(x => x.item is not null) + .Select(x => (x.item!, x.itemCount)) ]; } else @@ -1381,9 +2182,9 @@ public sealed class BaseItemRepository .. query .AsEnumerable() .Where(e => e is not null) - .Select(e => (Item: DeserializeBaseItem(e, filter.SkipDeserialization), ItemCounts: (ItemCounts?)null)) - .Where(e => e.Item is not null) - .Select(e => (e.Item!, e.ItemCounts)) + .Select(e => DeserializeBaseItem(e, filter.SkipDeserialization)) + .Where(item => item is not null) + .Select(item => (item!, (ItemCounts?)null)) ]; } @@ -1392,7 +2193,7 @@ public sealed class BaseItemRepository private static void PrepareFilterQuery(InternalItemsQuery query) { - if (query.Limit.HasValue && query.Limit.Value > 0 && query.EnableGroupByMetadataKey) + if (query.Limit.HasValue && query.EnableGroupByMetadataKey) { query.Limit = query.Limit.Value + 4; } @@ -1404,10 +2205,10 @@ public sealed class BaseItemRepository } /// <summary> - /// Gets the clean value for search and sorting purposes. + /// Normalizes a value for clean comparison by removing diacritics and converting to lowercase. /// </summary> /// <param name="value">The value to clean.</param> - /// <returns>The cleaned value.</returns> + /// <returns>The normalized value.</returns> public static string GetCleanValue(string value) { if (string.IsNullOrWhiteSpace(value)) @@ -1415,42 +2216,7 @@ public sealed class BaseItemRepository return value; } - var noDiacritics = value.RemoveDiacritics(); - - // Build a string where any punctuation or symbol is treated as a separator (space). - var sb = new StringBuilder(noDiacritics.Length); - var previousWasSpace = false; - foreach (var ch in noDiacritics) - { - char outCh; - if (char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch)) - { - outCh = ch; - } - else - { - outCh = ' '; - } - - // normalize any whitespace character to a single ASCII space. - if (char.IsWhiteSpace(outCh)) - { - if (!previousWasSpace) - { - sb.Append(' '); - previousWasSpace = true; - } - } - else - { - sb.Append(outCh); - previousWasSpace = false; - } - } - - // trim leading/trailing spaces that may have been added. - var collapsed = sb.ToString().Trim(); - return collapsed.ToLowerInvariant(); + return value.RemoveDiacritics().ToLowerInvariant(); } private List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags) @@ -1602,37 +2368,27 @@ public sealed class BaseItemRepository var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray(); var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm); - if (hasSearch) - { - orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy]; - } - else if (orderBy.Length == 0) - { - return query.OrderBy(e => e.SortName); - } - IOrderedQueryable<BaseItemEntity>? orderedQuery = null; - // When searching, prioritize by match quality: exact match > prefix match > contains if (hasSearch) { - orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!)); + var relevanceExpression = OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!); + orderedQuery = query.OrderBy(relevanceExpression); } - var firstOrdering = orderBy.FirstOrDefault(); - if (firstOrdering != default) + if (orderBy.Length > 0) { + var firstOrdering = orderBy[0]; var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context); + if (orderedQuery is null) { - // No search relevance ordering, start fresh orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending ? query.OrderBy(expression) : query.OrderByDescending(expression); } else { - // Search relevance ordering already applied, chain with ThenBy orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending ? orderedQuery.ThenBy(expression) : orderedQuery.ThenByDescending(expression); @@ -1640,26 +2396,32 @@ public sealed class BaseItemRepository if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName) { - orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending + orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending ? orderedQuery.ThenBy(e => e.Name) : orderedQuery.ThenByDescending(e => e.Name); } - } - foreach (var item in orderBy.Skip(1)) - { - var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context); - if (item.SortOrder == SortOrder.Ascending) + foreach (var item in orderBy.Skip(1)) { - orderedQuery = orderedQuery!.ThenBy(expression); - } - else - { - orderedQuery = orderedQuery!.ThenByDescending(expression); + expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context); + orderedQuery = item.SortOrder == SortOrder.Ascending + ? orderedQuery.ThenBy(expression) + : orderedQuery.ThenByDescending(expression); } } - return orderedQuery ?? query; + if (orderedQuery is null) + { + return query.OrderBy(e => e.SortName); + } + + // Add SortName as final tiebreaker + if (!hasSearch && (orderBy.Length == 0 || orderBy.All(o => o.OrderBy is not ItemSortBy.SortName and not ItemSortBy.Name))) + { + orderedQuery = orderedQuery.ThenBy(e => e.SortName); + } + + return orderedQuery; } private IQueryable<BaseItemEntity> TranslateQuery( @@ -1787,15 +2549,17 @@ public sealed class BaseItemRepository if (!string.IsNullOrEmpty(filter.SearchTerm)) { var cleanedSearchTerm = GetCleanValue(filter.SearchTerm); - var originalSearchTerm = filter.SearchTerm.ToLower(); + var originalSearchTerm = filter.SearchTerm; if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f))) { cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%"; - baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm))); + var likeSearchTerm = $"%{originalSearchTerm.Trim('%')}%"; + baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeSearchTerm))); } else { - baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm))); + var likeSearchTerm = $"%{originalSearchTerm}%"; + baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeSearchTerm))); } } @@ -2029,28 +2793,29 @@ public sealed class BaseItemRepository } else { + var likeNameContains = $"%{nameContains}%"; baseQuery = baseQuery.Where(e => e.CleanName!.Contains(nameContains) - || e.OriginalTitle!.ToLower().Contains(nameContains!)); + || EF.Functions.Like(e.OriginalTitle, likeNameContains)); } } if (!string.IsNullOrWhiteSpace(filter.NameStartsWith)) { - var startsWithLower = filter.NameStartsWith.ToLowerInvariant(); - baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower)); + var nameStartsWithLower = filter.NameStartsWith.ToLowerInvariant(); + baseQuery = baseQuery.Where(e => e.SortName!.ToLower().StartsWith(nameStartsWithLower)); } if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater)) { var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant(); - baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0); + baseQuery = baseQuery.Where(e => e.SortName!.ToLower().CompareTo(startsOrGreaterLower) >= 0); } if (!string.IsNullOrWhiteSpace(filter.NameLessThan)) { var lessThanLower = filter.NameLessThan.ToLowerInvariant(); - baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 0); + baseQuery = baseQuery.Where(e => e.SortName!.ToLower().CompareTo(lessThanLower) < 0); } if (filter.ImageTypes.Length > 0) @@ -2082,21 +2847,46 @@ public sealed class BaseItemRepository // We should probably figure this out for all folders, but for right now, this is the only place where we need it if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series) { - baseQuery = baseQuery.Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)) - .Where(e => e.IsFolder == false && e.IsVirtualItem == false) - .Where(f => f.UserData!.FirstOrDefault(e => e.UserId == filter.User!.Id && e.Played)!.Played) - .Any(f => f.SeriesPresentationUniqueKey == e.PresentationUniqueKey) == filter.IsPlayed); + // Get distinct SeriesPresentationUniqueKeys that have at least one played episode + var playedSeriesKeys = context.BaseItems + .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesPresentationUniqueKey != null) + .Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.Played)) + .Select(e => e.SeriesPresentationUniqueKey!) + .Distinct() + .ToHashSet(); + + if (filter.IsPlayed.Value) + { + if (playedSeriesKeys.Count == 0) + { + baseQuery = baseQuery.Where(e => false); + } + else + { + baseQuery = baseQuery.Where(e => playedSeriesKeys.Contains(e.PresentationUniqueKey!)); + } + } + else + { + if (playedSeriesKeys.Count == 0) + { + // No played episodes - all series are unplayed, no filter needed + } + else + { + baseQuery = baseQuery.Where(e => !playedSeriesKeys.Contains(e.PresentationUniqueKey!)); + } + } + } + else if (filter.IsPlayed.Value) + { + baseQuery = baseQuery + .Where(e => e.UserData!.Any(f => f.UserId == filter.User!.Id && f.Played)); } else { baseQuery = baseQuery - .Select(e => new - { - IsPlayed = e.UserData!.Where(f => f.UserId == filter.User!.Id).Select(f => (bool?)f.Played).FirstOrDefault() ?? false, - Item = e - }) - .Where(e => e.IsPlayed == filter.IsPlayed) - .Select(f => f.Item); + .Where(e => !e.UserData!.Any(f => f.UserId == filter.User!.Id && f.Played)); } } @@ -2184,8 +2974,10 @@ public sealed class BaseItemRepository if (filter.OfficialRatings.Length > 0) { - baseQuery = baseQuery - .Where(e => filter.OfficialRatings.Contains(e.OfficialRating)); + var ratings = filter.OfficialRatings; + Expression<Func<BaseItemEntity, bool>> hasOfficialRating = e => ratings.Contains(e.OfficialRating); + + baseQuery = baseQuery.WhereItemOrDescendantMatches(context, hasOfficialRating); } Expression<Func<BaseItemEntity, bool>>? minParentalRatingFilter = null; @@ -2268,16 +3060,12 @@ public sealed class BaseItemRepository if (filter.HasOfficialRating.HasValue) { - if (filter.HasOfficialRating.Value) - { - baseQuery = baseQuery - .Where(e => e.OfficialRating != null && e.OfficialRating != string.Empty); - } - else - { - baseQuery = baseQuery - .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty); - } + Expression<Func<BaseItemEntity, bool>> hasRating = + e => e.OfficialRating != null && e.OfficialRating != string.Empty; + + baseQuery = filter.HasOfficialRating.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasRating) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasRating); } if (filter.HasOverview.HasValue) @@ -2321,38 +3109,86 @@ public sealed class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage)) { + var lang = filter.HasNoAudioTrackWithLanguage; + var foldersWithAudio = _descendantQueryProvider.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang)); + baseQuery = baseQuery - .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio && f.Language == filter.HasNoAudioTrackWithLanguage)); + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang)) + || (e.IsFolder && !foldersWithAudio.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage)) { + var lang = filter.HasNoInternalSubtitleTrackWithLanguage; + var foldersWithSubtitles = _descendantQueryProvider.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false)); + baseQuery = baseQuery - .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && !f.IsExternal && f.Language == filter.HasNoInternalSubtitleTrackWithLanguage)); + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage)) { + var lang = filter.HasNoExternalSubtitleTrackWithLanguage; + var foldersWithSubtitles = _descendantQueryProvider.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true)); + baseQuery = baseQuery - .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.IsExternal && f.Language == filter.HasNoExternalSubtitleTrackWithLanguage)); + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage)) { + var lang = filter.HasNoSubtitleTrackWithLanguage; + var foldersWithSubtitles = _descendantQueryProvider.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang)); + baseQuery = baseQuery - .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.Language == filter.HasNoSubtitleTrackWithLanguage)); + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (filter.HasSubtitles.HasValue) { - baseQuery = baseQuery - .Where(e => e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle) == filter.HasSubtitles.Value); + var hasSubtitles = filter.HasSubtitles.Value; + var foldersWithSubtitles = _descendantQueryProvider.GetFolderIdsMatching(context, new HasSubtitles()); + if (hasSubtitles) + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)) + || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); + } + else + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); + } } if (filter.HasChapterImages.HasValue) { - baseQuery = baseQuery - .Where(e => e.Chapters!.Any(f => f.ImagePath != null) == filter.HasChapterImages.Value); + var hasChapterImages = filter.HasChapterImages.Value; + var foldersWithChapterImages = _descendantQueryProvider.GetFolderIdsMatching(context, new HasChapterImages()); + if (hasChapterImages) + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null)) + || (e.IsFolder && foldersWithChapterImages.Contains(e.Id))); + } + else + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)) + || (e.IsFolder && !foldersWithChapterImages.Contains(e.Id))); + } } if (filter.HasDeadParentId.HasValue && filter.HasDeadParentId.Value) @@ -2467,22 +3303,22 @@ public sealed class BaseItemRepository if (filter.HasImdbId.HasValue) { baseQuery = filter.HasImdbId.Value - ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower())) - : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower())); + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == ImdbProviderName)) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != ImdbProviderName)); } if (filter.HasTmdbId.HasValue) { baseQuery = filter.HasTmdbId.Value - ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower())) - : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower())); + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == TmdbProviderName)) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TmdbProviderName)); } if (filter.HasTvdbId.HasValue) { baseQuery = filter.HasTvdbId.Value - ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower())) - : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower())); + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == TvdbProviderName)) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TvdbProviderName)); } var queryTopParentIds = filter.TopParentIds; @@ -2503,7 +3339,8 @@ public sealed class BaseItemRepository if (filter.AncestorIds.Length > 0) { - baseQuery = baseQuery.Where(e => e.Parents!.Any(f => filter.AncestorIds.Contains(f.ParentItemId))); + var ancestorFilter = filter.AncestorIds.OneOrManyExpressionBuilder<AncestorId, Guid>(f => f.ParentItemId); + baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter)); } if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey)) @@ -2522,8 +3359,10 @@ public sealed class BaseItemRepository { var excludedTags = filter.ExcludeInheritedTags; baseQuery = baseQuery.Where(e => - !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) - && (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)))); + !context.ItemValuesMap.Any(f => + f.ItemValue.Type == ItemValueType.Tags + && excludedTags.Contains(f.ItemValue.CleanValue) + && (f.ItemId == e.Id || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value)))); } if (filter.IncludeInheritedTags.Length > 0) @@ -2531,10 +3370,10 @@ public sealed class BaseItemRepository var includeTags = filter.IncludeInheritedTags; var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist; baseQuery = baseQuery.Where(e => - e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) - - // For seasons and episodes, we also need to check the parent series' tags. - || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))) + context.ItemValuesMap.Any(f => + f.ItemValue.Type == ItemValueType.Tags + && includeTags.Contains(f.ItemValue.CleanValue) + && (f.ItemId == e.Id || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value))) // A playlist should be accessible to its owner regardless of allowed tags || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); @@ -2556,64 +3395,131 @@ public sealed class BaseItemRepository if (filter.VideoTypes.Length > 0) { - var videoTypeBs = filter.VideoTypes.Select(e => $"\"VideoType\":\"{e}\""); - baseQuery = baseQuery - .Where(e => videoTypeBs.Any(f => e.Data!.Contains(f))); + var videoTypeBs = filter.VideoTypes.Select(vt => $"\"VideoType\":\"{vt}\"").ToArray(); + Expression<Func<BaseItemEntity, bool>> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)); + baseQuery = baseQuery.WhereItemOrDescendantMatches(context, hasVideoType); } if (filter.Is3D.HasValue) { - if (filter.Is3D.Value) - { - baseQuery = baseQuery - .Where(e => e.Data!.Contains("Video3DFormat")); - } - else - { - baseQuery = baseQuery - .Where(e => !e.Data!.Contains("Video3DFormat")); - } + Expression<Func<BaseItemEntity, bool>> is3D = e => e.Data!.Contains("Video3DFormat"); + + baseQuery = filter.Is3D.Value + ? baseQuery.WhereItemOrDescendantMatches(context, is3D) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, is3D); } if (filter.IsPlaceHolder.HasValue) { - if (filter.IsPlaceHolder.Value) - { - baseQuery = baseQuery - .Where(e => e.Data!.Contains("IsPlaceHolder\":true")); - } - else - { - baseQuery = baseQuery - .Where(e => !e.Data!.Contains("IsPlaceHolder\":true")); - } + Expression<Func<BaseItemEntity, bool>> isPlaceHolder = e => e.Data!.Contains("IsPlaceHolder\":true"); + + baseQuery = filter.IsPlaceHolder.Value + ? baseQuery.WhereItemOrDescendantMatches(context, isPlaceHolder) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, isPlaceHolder); } if (filter.HasSpecialFeature.HasValue) { - if (filter.HasSpecialFeature.Value) + var itemsWithExtras = context.BaseItems + .Where(extra => extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value) + .Distinct(); + + Expression<Func<BaseItemEntity, bool>> hasExtras = e => itemsWithExtras.Contains(e.Id); + + baseQuery = filter.HasSpecialFeature.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasExtras) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasExtras); + } + + if (filter.HasTrailer.HasValue) + { + var trailerOwnerIds = context.BaseItems + .Where(extra => extra.ExtraType == BaseItemExtraType.Trailer && extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value); + + Expression<Func<BaseItemEntity, bool>> hasTrailer = e => trailerOwnerIds.Contains(e.Id); + + baseQuery = filter.HasTrailer.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasTrailer) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasTrailer); + } + + if (filter.HasThemeSong.HasValue) + { + var themeSongOwnerIds = context.BaseItems + .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeSong && extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value); + + Expression<Func<BaseItemEntity, bool>> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id); + + baseQuery = filter.HasThemeSong.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasThemeSong) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasThemeSong); + } + + if (filter.HasThemeVideo.HasValue) + { + var themeVideoOwnerIds = context.BaseItems + .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeVideo && extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value); + + Expression<Func<BaseItemEntity, bool>> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id); + + baseQuery = filter.HasThemeVideo.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasThemeVideo) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasThemeVideo); + } + + if (filter.AiredDuringSeason.HasValue) + { + var seasonNumber = filter.AiredDuringSeason.Value; + if (seasonNumber < 1) { - baseQuery = baseQuery - .Where(e => e.Extras != null && e.Extras.Count > 0); + baseQuery = baseQuery.Where(e => e.ParentIndexNumber == seasonNumber); } else { - baseQuery = baseQuery - .Where(e => e.Extras == null || e.Extras.Count == 0); + var seasonStr = seasonNumber.ToString(CultureInfo.InvariantCulture); + baseQuery = baseQuery.Where(e => + e.ParentIndexNumber == seasonNumber + || (e.Data != null && ( + e.Data.Contains("\"AirsAfterSeasonNumber\":" + seasonStr) + || e.Data.Contains("\"AirsBeforeSeasonNumber\":" + seasonStr)))); } } - if (filter.HasTrailer.HasValue || filter.HasThemeSong.HasValue || filter.HasThemeVideo.HasValue) + if (filter.AdjacentTo.HasValue && !filter.AdjacentTo.Value.IsEmpty()) { - if (filter.HasTrailer.GetValueOrDefault() || filter.HasThemeSong.GetValueOrDefault() || filter.HasThemeVideo.GetValueOrDefault()) + var adjacentToId = filter.AdjacentTo.Value; + var targetItem = context.BaseItems.Where(e => e.Id == adjacentToId).Select(e => new { e.SortName, e.Id }).FirstOrDefault(); + if (targetItem is not null) { - baseQuery = baseQuery - .Where(e => e.Extras != null && e.Extras.Count > 0); - } - else - { - baseQuery = baseQuery - .Where(e => e.Extras == null || e.Extras.Count == 0); + var targetSortName = targetItem.SortName ?? string.Empty; + var prevId = context.BaseItems + .Where(e => string.Compare(e.SortName, targetSortName) < 0) + .OrderByDescending(e => e.SortName) + .Select(e => e.Id) + .FirstOrDefault(); + + var nextId = context.BaseItems + .Where(e => string.Compare(e.SortName, targetSortName) > 0) + .OrderBy(e => e.SortName) + .Select(e => e.Id) + .FirstOrDefault(); + + var adjacentIds = new List<Guid> { adjacentToId }; + if (prevId != Guid.Empty) + { + adjacentIds.Add(prevId); + } + + if (nextId != Guid.Empty) + { + adjacentIds.Add(nextId); + } + + baseQuery = baseQuery.Where(e => adjacentIds.Contains(e.Id)); } } @@ -2637,49 +3543,215 @@ public sealed class BaseItemRepository if (recursive) { - var folderList = TraverseHirachyDown(id, dbContext, item => (item.IsFolder || item.IsVirtualItem)); + var descendantIds = _descendantQueryProvider.GetAllDescendantIds(dbContext, id); return dbContext.BaseItems - .Where(e => folderList.Contains(e.ParentId!.Value) && !e.IsFolder && !e.IsVirtualItem) + .Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem) .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); } return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); } - private static HashSet<Guid> TraverseHirachyDown(Guid parentId, JellyfinDbContext dbContext, Expression<Func<BaseItemEntity, bool>>? filter = null) + /// <inheritdoc/> + public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId) { - var folderStack = new HashSet<Guid>() - { - parentId - }; - var folderList = new HashSet<Guid>() - { - parentId - }; + ArgumentNullException.ThrowIfNull(filter.User); + using var dbContext = _dbProvider.CreateDbContext(); + + var baseQuery = BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played)); + } + + /// <inheritdoc/> + public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId) + { + using var dbContext = _dbProvider.CreateDbContext(); + + var baseQuery = BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + return baseQuery.Count(); + } + + /// <inheritdoc/> + public (int Played, int Total) GetPlayedAndTotalCount(InternalItemsQuery filter, Guid ancestorId) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(filter.User); + using var dbContext = _dbProvider.CreateDbContext(); - while (folderStack.Count != 0) + var baseQuery = BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); + } + + /// <inheritdoc/> + public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(filter.User); + using var dbContext = _dbProvider.CreateDbContext(); + + var allDescendantIds = _descendantQueryProvider.GetAllDescendantIds(dbContext, parentId); + var baseQuery = dbContext.BaseItems + .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem); + baseQuery = ApplyAccessFiltering(dbContext, baseQuery, filter); + + return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); + } + + /// <inheritdoc/> + public IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null) + { + using var dbContext = _dbProvider.CreateDbContext(); + + var query = dbContext.LinkedChildren + .Where(lc => lc.ParentId.Equals(parentId)); + + if (childType.HasValue) { - var items = folderStack.ToArray(); - folderStack.Clear(); - var query = dbContext.BaseItems - .WhereOneOrMany(items, e => e.ParentId!.Value); + query = query.Where(lc => (int)lc.ChildType == childType.Value); + } - if (filter != null) - { - query = query.Where(filter); - } + return query + .Select(lc => lc.ChildId) + .ToList(); + } - foreach (var item in query.Select(e => e.Id).ToArray()) + private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId) + { + var result = query + .Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played)) + .GroupBy(_ => 1) + .Select(g => new { - if (folderList.Add(item)) - { - folderStack.Add(item); - } - } + Total = g.Count(), + Played = g.Count(isPlayed => isPlayed) + }) + .FirstOrDefault(); + + return result is null ? (0, 0) : (result.Played, result.Total); + } + + /// <inheritdoc/> + public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId) + { + ArgumentNullException.ThrowIfNull(parentIds); + + if (parentIds.Count == 0) + { + return new Dictionary<Guid, int>(); + } + + using var dbContext = _dbProvider.CreateDbContext(); + + // Convert to array for EF Core Contains support + var parentIdsArray = parentIds.ToArray(); + + // Count hierarchical children (immediate children via ParentId) + var hierarchicalCounts = dbContext.BaseItems + .Where(b => b.ParentId.HasValue && parentIdsArray.Contains(b.ParentId.Value)) + .GroupBy(b => b.ParentId!.Value) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.ParentId, x => x.Count); + + // Count linked children (BoxSets, Playlists, etc.) + var linkedCounts = dbContext.LinkedChildren + .Where(lc => parentIdsArray.Contains(lc.ParentId)) + .GroupBy(lc => lc.ParentId) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.ParentId, x => x.Count); + + // Merge results + var result = new Dictionary<Guid, int>(); + foreach (var parentId in parentIds) + { + var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0); + var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0); + + // If there are linked children, use that count (matches Folder.GetChildCount logic) + // Otherwise use hierarchical count + result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount; + } + + return result; + } + + /// <summary> + /// Builds a query for descendants of an ancestor with user access filtering applied. + /// Uses recursive CTE to traverse both hierarchical (AncestorIds) and linked (LinkedChildren) relationships. + /// </summary> + private IQueryable<BaseItemEntity> BuildAccessFilteredDescendantsQuery( + JellyfinDbContext context, + InternalItemsQuery filter, + Guid ancestorId) + { + // Use recursive CTE to get all descendants (hierarchical and linked) + var allDescendantIds = _descendantQueryProvider.GetAllDescendantIds(context, ancestorId); + + var baseQuery = context.BaseItems + .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem); + + return ApplyAccessFiltering(context, baseQuery, filter); + } + + /// <summary> + /// Applies user access filtering to a query. + /// Includes TopParentIds, parental rating, and tag filtering. + /// </summary> + private IQueryable<BaseItemEntity> ApplyAccessFiltering( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter) + { + // Apply TopParentIds filtering (library folder access) + if (filter.TopParentIds.Length > 0) + { + var topParentIds = filter.TopParentIds; + baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value)); } - return folderList; + // Apply parental rating filtering + if (filter.MaxParentalRating is not null) + { + var maxScore = filter.MaxParentalRating.Score; + var maxSubScore = filter.MaxParentalRating.SubScore ?? 0; + + baseQuery = baseQuery.Where(e => + e.InheritedParentalRatingValue == null || + e.InheritedParentalRatingValue < maxScore || + (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)); + } + + // Apply block unrated items filtering + if (filter.BlockUnratedItems.Length > 0) + { + var unratedItemTypes = filter.BlockUnratedItems.Select(f => f.ToString()).ToArray(); + baseQuery = baseQuery.Where(e => + e.InheritedParentalRatingValue != null || !unratedItemTypes.Contains(e.UnratedType)); + } + + // Apply excluded tags filtering (blocked tags) + if (filter.ExcludeInheritedTags.Length > 0) + { + var excludedTags = filter.ExcludeInheritedTags; + baseQuery = baseQuery.Where(e => + !context.ItemValuesMap.Any(f => + f.ItemValue.Type == ItemValueType.Tags + && excludedTags.Contains(f.ItemValue.CleanValue) + && (f.ItemId == e.Id || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value)))); + } + + // Apply included tags filtering (allowed tags - item must have at least one) + if (filter.IncludeInheritedTags.Length > 0) + { + var includeTags = filter.IncludeInheritedTags; + baseQuery = baseQuery.Where(e => + context.ItemValuesMap.Any(f => + f.ItemValue.Type == ItemValueType.Tags + && includeTags.Contains(f.ItemValue.CleanValue) + && (f.ItemId == e.Id || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value)))); + } + + return baseQuery; } /// <inheritdoc/> diff --git a/MediaBrowser.Controller/Dto/IDtoService.cs b/MediaBrowser.Controller/Dto/IDtoService.cs index f1d507fcbd..8dc6f0aa68 100644 --- a/MediaBrowser.Controller/Dto/IDtoService.cs +++ b/MediaBrowser.Controller/Dto/IDtoService.cs @@ -36,8 +36,9 @@ namespace MediaBrowser.Controller.Dto /// <param name="options">The options.</param> /// <param name="user">The user.</param> /// <param name="owner">The owner.</param> + /// <param name="skipVisibilityCheck">Skip redundant visibility check if items are already filtered.</param> /// <returns>The <see cref="IReadOnlyList{T}"/> of <see cref="BaseItemDto"/>.</returns> - IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null); + IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null, bool skipVisibilityCheck = false); /// <summary> /// Gets the item by name dto. diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index eb7daeb532..13af7a6178 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1806,10 +1806,23 @@ namespace MediaBrowser.Controller.Entities return item; } +#pragma warning disable CS0618 // Type or member is obsolete - fallback for legacy LinkedChild data private BaseItem FindLinkedChild(LinkedChild info) { - var path = info.Path; + // First try to find by ItemId (new preferred method) + if (info.ItemId.HasValue && !info.ItemId.Value.Equals(Guid.Empty)) + { + var item = LibraryManager.GetItemById(info.ItemId.Value); + if (item is not null) + { + return item; + } + Logger.LogWarning("Unable to find linked item by ItemId {0}", info.ItemId); + } + + // Fall back to Path (legacy method) + var path = info.Path; if (!string.IsNullOrEmpty(path)) { path = FileSystem.MakeAbsolutePath(ContainingFolderPath, path); @@ -1824,13 +1837,14 @@ namespace MediaBrowser.Controller.Entities return itemByPath; } + // Fall back to LibraryItemId (legacy method) if (!string.IsNullOrEmpty(info.LibraryItemId)) { var item = LibraryManager.GetItemById(info.LibraryItemId); if (item is null) { - Logger.LogWarning("Unable to find linked item at path {0}", info.Path); + Logger.LogWarning("Unable to find linked item by LibraryItemId {0}", info.LibraryItemId); } return item; @@ -1838,6 +1852,7 @@ namespace MediaBrowser.Controller.Entities return null; } +#pragma warning restore CS0618 /// <summary> /// Adds a studio to the item. diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index ca79e62454..cf615788ee 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -45,6 +45,11 @@ namespace MediaBrowser.Controller.Entities } /// <summary> + /// Event raised when library options are updated for any collection folder. + /// </summary> + public static event EventHandler<LibraryOptionsUpdatedEventArgs> LibraryOptionsUpdated; + + /// <summary> /// Gets the display preferences id. /// </summary> /// <remarks> @@ -168,6 +173,8 @@ namespace MediaBrowser.Controller.Entities } XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path)); + + LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, options)); } public static void OnCollectionFolderChange() diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index d2a3290c47..6338e54292 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -59,6 +59,10 @@ namespace MediaBrowser.Controller.Entities /// <value><c>true</c> if this instance is root; otherwise, <c>false</c>.</value> public bool IsRoot { get; set; } + /// <summary> + /// Gets or sets the linked children. + /// </summary> + [JsonIgnore] public LinkedChild[] LinkedChildren { get; set; } [JsonIgnore] @@ -455,6 +459,14 @@ namespace MediaBrowser.Controller.Entities // If it's an AggregateFolder, don't remove if (shouldRemove && itemsRemoved.Count > 0) { + // Build a set of paths that are alternate versions of valid children + // These items should not be deleted - they're managed by their primary video + var alternateVersionPaths = validChildren + .OfType<Video>() + .SelectMany(v => v.LocalAlternateVersions ?? []) + .Where(p => !string.IsNullOrEmpty(p)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in itemsRemoved) { if (!item.CanDelete()) @@ -463,6 +475,24 @@ namespace MediaBrowser.Controller.Entities continue; } + // Skip items that are alternate versions of another video + if (item is Video video) + { + // Check via PrimaryVersionId + if (!string.IsNullOrEmpty(video.PrimaryVersionId)) + { + Logger.LogDebug("Item is an alternate version (via PrimaryVersionId), skipping deletion: {Path}", item.Path ?? item.Name); + continue; + } + + // Check if path is in LocalAlternateVersions of any valid child + if (!string.IsNullOrEmpty(item.Path) && alternateVersionPaths.Contains(item.Path)) + { + Logger.LogDebug("Item path matches an alternate version, skipping deletion: {Path}", item.Path); + continue; + } + } + if (item.IsFileProtocol) { Logger.LogDebug("Removed item: {Path}", item.Path); @@ -806,104 +836,12 @@ namespace MediaBrowser.Controller.Entities private bool RequiresPostFiltering(InternalItemsQuery query) { - if (LinkedChildren.Length > 0) - { - if (this is not ICollectionFolder) - { - Logger.LogDebug("{Type}: Query requires post-filtering due to LinkedChildren.", GetType().Name); - return true; - } - } - - // Filter by Video3DFormat - if (query.Is3D.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to Is3D"); - return true; - } - - if (query.HasOfficialRating.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasOfficialRating"); - return true; - } - - if (query.IsPlaceHolder.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to IsPlaceHolder"); - return true; - } - - if (query.HasSpecialFeature.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasSpecialFeature"); - return true; - } - - if (query.HasSubtitles.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasSubtitles"); - return true; - } - - if (query.HasTrailer.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasTrailer"); - return true; - } - - if (query.HasThemeSong.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasThemeSong"); - return true; - } - - if (query.HasThemeVideo.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasThemeVideo"); - return true; - } - - // Filter by VideoType - if (query.VideoTypes.Length > 0) - { - Logger.LogDebug("Query requires post-filtering due to VideoTypes"); - return true; - } - if (CollapseBoxSetItems(query, this, query.User, ConfigurationManager)) { Logger.LogDebug("Query requires post-filtering due to CollapseBoxSetItems"); return true; } - if (!query.AdjacentTo.IsNullOrEmpty()) - { - Logger.LogDebug("Query requires post-filtering due to AdjacentTo"); - return true; - } - - if (query.SeriesStatuses.Length > 0) - { - Logger.LogDebug("Query requires post-filtering due to SeriesStatuses"); - return true; - } - - if (query.AiredDuringSeason.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to AiredDuringSeason"); - return true; - } - - if (query.IsPlayed.HasValue) - { - if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(BaseItemKind.Series)) - { - Logger.LogDebug("Query requires post-filtering due to IsPlayed"); - return true; - } - } - return false; } @@ -1012,29 +950,6 @@ namespace MediaBrowser.Controller.Entities items = CollapseBoxSetItemsIfNeeded(items, query, this, user, ConfigurationManager, CollectionManager); } -#pragma warning disable CA1309 - if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater)) - { - items = items.Where(i => string.Compare(query.NameStartsWithOrGreater, i.SortName, StringComparison.InvariantCultureIgnoreCase) < 1); - } - - if (!string.IsNullOrEmpty(query.NameStartsWith)) - { - items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase)); - } - - if (!string.IsNullOrEmpty(query.NameLessThan)) - { - items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.InvariantCultureIgnoreCase) == 1); - } -#pragma warning restore CA1309 - - // This must be the last filter - if (!query.AdjacentTo.IsNullOrEmpty()) - { - items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); - } - var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList(); var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager); @@ -1664,11 +1579,13 @@ namespace MediaBrowser.Controller.Entities if (!string.IsNullOrEmpty(resolvedPath)) { +#pragma warning disable CS0618 // Type or member is obsolete - shortcuts require Path for lazy ItemId resolution return new LinkedChild { Path = resolvedPath, Type = LinkedChildType.Shortcut }; +#pragma warning restore CS0618 } Logger.LogError("Error resolving shortcut {0}", i.FullName); @@ -1786,38 +1703,42 @@ namespace MediaBrowser.Controller.Entities return; } - if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)) - { - itemDto.RecursiveItemCount = GetRecursiveChildCount(user); - } - - if (SupportsPlayedStatus) + if (SupportsPlayedStatus || (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount))) { - var unplayedQueryResult = GetItems(new InternalItemsQuery(user) - { - Recursive = true, - IsFolder = false, - IsVirtualItem = false, - EnableTotalRecordCount = true, - Limit = 0, - IsPlayed = false, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }).TotalRecordCount; + var query = new InternalItemsQuery(user); + LibraryManager.ConfigureUserAccess(query, user); - dto.UnplayedItemCount = unplayedQueryResult; + int playedCount; + int totalCount; - if (itemDto?.RecursiveItemCount > 0) + if (LinkedChildren.Length > 0) { - var unplayedPercentage = ((double)unplayedQueryResult / itemDto.RecursiveItemCount.Value) * 100; - dto.PlayedPercentage = 100 - unplayedPercentage; - dto.Played = dto.PlayedPercentage.Value >= 100; + (playedCount, totalCount) = ItemRepository.GetPlayedAndTotalCountFromLinkedChildren(query, Id); } else { - dto.Played = (dto.UnplayedItemCount ?? 0) == 0; + (playedCount, totalCount) = ItemRepository.GetPlayedAndTotalCount(query, Id); + } + + if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)) + { + itemDto.RecursiveItemCount = totalCount; + } + + if (SupportsPlayedStatus) + { + var unplayedCount = totalCount - playedCount; + dto.UnplayedItemCount = unplayedCount; + + if (totalCount > 0) + { + dto.PlayedPercentage = playedCount / (double)totalCount * 100; + dto.Played = playedCount >= totalCount; + } + else + { + dto.Played = true; + } } } } diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 24fe3bb32d..b36ea627d8 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -33,6 +33,7 @@ namespace MediaBrowser.Controller.Entities ExcludeItemIds = Array.Empty<Guid>(); ExcludeItemTypes = Array.Empty<BaseItemKind>(); ExcludeTags = Array.Empty<string>(); + ExtraTypes = Array.Empty<ExtraType>(); GenreIds = Array.Empty<Guid>(); Genres = Array.Empty<string>(); GroupByPresentationUniqueKey = true; @@ -44,6 +45,7 @@ namespace MediaBrowser.Controller.Entities MediaTypes = Array.Empty<MediaType>(); OfficialRatings = Array.Empty<string>(); OrderBy = Array.Empty<(ItemSortBy, SortOrder)>(); + OwnerIds = Array.Empty<Guid>(); PersonIds = Array.Empty<Guid>(); PersonTypes = Array.Empty<string>(); PresetViews = Array.Empty<CollectionType?>(); @@ -369,6 +371,8 @@ namespace MediaBrowser.Controller.Entities public bool SkipDeserialization { get; set; } + public bool IncludeExtras { get; set; } + public void SetUser(User user) { var maxRating = user.MaxParentalRatingScore; diff --git a/MediaBrowser.Controller/Entities/LinkedChild.cs b/MediaBrowser.Controller/Entities/LinkedChild.cs index 98e4f525f5..a3aa9dd0c9 100644 --- a/MediaBrowser.Controller/Entities/LinkedChild.cs +++ b/MediaBrowser.Controller/Entities/LinkedChild.cs @@ -3,7 +3,6 @@ #pragma warning disable CS1591 using System; -using System.Globalization; namespace MediaBrowser.Controller.Entities { @@ -13,10 +12,18 @@ namespace MediaBrowser.Controller.Entities { } + /// <summary> + /// Gets or sets the path. + /// </summary> + [Obsolete("Use ItemId instead")] public string Path { get; set; } public LinkedChildType Type { get; set; } + /// <summary> + /// Gets or sets the library item id. + /// </summary> + [Obsolete("Use ItemId instead")] public string LibraryItemId { get; set; } /// <summary> @@ -28,18 +35,11 @@ namespace MediaBrowser.Controller.Entities { ArgumentNullException.ThrowIfNull(item); - var child = new LinkedChild + return new LinkedChild { - Path = item.Path, + ItemId = item.Id, Type = LinkedChildType.Manual }; - - if (string.IsNullOrEmpty(child.Path)) - { - child.LibraryItemId = item.Id.ToString("N", CultureInfo.InvariantCulture); - } - - return child; } } } diff --git a/MediaBrowser.Controller/Entities/LinkedChildComparer.cs b/MediaBrowser.Controller/Entities/LinkedChildComparer.cs index 4f13ac61fe..8b611345f4 100644 --- a/MediaBrowser.Controller/Entities/LinkedChildComparer.cs +++ b/MediaBrowser.Controller/Entities/LinkedChildComparer.cs @@ -19,17 +19,34 @@ namespace MediaBrowser.Controller.Entities public bool Equals(LinkedChild x, LinkedChild y) { - if (x.Type == y.Type) + if (x.Type != y.Type) { - return _fileSystem.AreEqual(x.Path, y.Path); + return false; } - return false; + // Compare by ItemId first (preferred) + if (x.ItemId.HasValue && y.ItemId.HasValue) + { + return x.ItemId.Value.Equals(y.ItemId.Value); + } + +#pragma warning disable CS0618 // Type or member is obsolete - fallback for shortcut/legacy comparison + // Fall back to Path comparison for shortcuts and legacy data + return _fileSystem.AreEqual(x.Path, y.Path); +#pragma warning restore CS0618 } public int GetHashCode(LinkedChild obj) { + // Use ItemId for hash if available, otherwise fall back to legacy fields + if (obj.ItemId.HasValue && !obj.ItemId.Value.Equals(Guid.Empty)) + { + return HashCode.Combine(obj.ItemId.Value, obj.Type); + } + +#pragma warning disable CS0618 // Type or member is obsolete - fallback for shortcut/legacy hashing return ((obj.Path ?? string.Empty) + (obj.LibraryItemId ?? string.Empty) + obj.Type).GetHashCode(StringComparison.Ordinal); +#pragma warning restore CS0618 } } } diff --git a/MediaBrowser.Controller/Entities/LinkedChildType.cs b/MediaBrowser.Controller/Entities/LinkedChildType.cs index 3bd260a102..5ce66a561f 100644 --- a/MediaBrowser.Controller/Entities/LinkedChildType.cs +++ b/MediaBrowser.Controller/Entities/LinkedChildType.cs @@ -13,6 +13,16 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Shortcut linked child. /// </summary> - Shortcut = 1 + Shortcut = 1, + + /// <summary> + /// Local alternate version (same item, different file path). + /// </summary> + LocalAlternateVersion = 2, + + /// <summary> + /// Linked alternate version (different item ID). + /// </summary> + LinkedAlternateVersion = 3 } } diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index 3999c3e076..c55a70a67b 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -37,9 +37,7 @@ namespace MediaBrowser.Controller.Entities.Movies /// <inheritdoc /> [JsonIgnore] - public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) - .ToArray(); + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray(); /// <summary> /// Gets or sets the display order. diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index 710b05e7f9..797f44e2d5 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -4,13 +4,13 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; namespace MediaBrowser.Controller.Entities.Movies { @@ -28,9 +28,7 @@ namespace MediaBrowser.Controller.Entities.Movies /// <inheritdoc /> [JsonIgnore] - public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) - .ToArray(); + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray(); /// <summary> /// Gets or sets the name of the TMDb collection. @@ -85,6 +83,34 @@ namespace MediaBrowser.Controller.Entities.Movies return info; } + protected override async Task RefreshMetadataForVersions(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) + { + var newOptions = new MetadataRefreshOptions(options) + { + SearchResult = null + }; + + var id = LibraryManager.GetNewItemId(path, typeof(Movie)); + if (LibraryManager.GetItemById(id) is not Movie movie) + { + movie = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Movie; + + newOptions.ForceSave = true; + } + + if (movie is null) + { + return; + } + + if (movie.OwnerId.Equals(Guid.Empty)) + { + movie.OwnerId = Id; + } + + await RefreshMetadataForOwnedItem(movie, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); + } + /// <inheritdoc /> public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index 6bdba36f9c..dbe6f94dfd 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -28,9 +28,7 @@ namespace MediaBrowser.Controller.Entities.TV /// <inheritdoc /> [JsonIgnore] - public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) - .ToArray(); + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray(); /// <summary> /// Gets or sets the season in which it aired. diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 6396631f99..ca9ac3f047 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -52,9 +52,7 @@ namespace MediaBrowser.Controller.Entities.TV /// <inheritdoc /> [JsonIgnore] - public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) - .ToArray(); + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray(); /// <summary> /// Gets or sets the display order. diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index bed7554b19..47d732c745 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -16,9 +16,7 @@ using MediaBrowser.Controller.TV; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.Extensions.Logging; -using Episode = MediaBrowser.Controller.Entities.TV.Episode; using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider; -using Series = MediaBrowser.Controller.Entities.TV.Series; namespace MediaBrowser.Controller.Entities { @@ -140,7 +138,7 @@ namespace MediaBrowser.Controller.Entities if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; } return parent.QueryRecursive(query); @@ -165,7 +163,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return _libraryManager.GetItemsResult(query); } @@ -176,7 +174,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { BaseItemKind.Series }; + query.IncludeItemTypes = [BaseItemKind.Series]; return _libraryManager.GetItemsResult(query); } @@ -187,7 +185,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { BaseItemKind.Episode }; + query.IncludeItemTypes = [BaseItemKind.Episode]; return _libraryManager.GetItemsResult(query); } @@ -198,7 +196,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return _libraryManager.GetItemsResult(query); } @@ -206,7 +204,7 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetMovieCollections(User user, InternalItemsQuery query) { query.Parent = null; - query.IncludeItemTypes = new[] { BaseItemKind.BoxSet }; + query.IncludeItemTypes = [BaseItemKind.BoxSet]; query.SetUser(user); query.Recursive = true; @@ -215,25 +213,25 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetMovieLatest(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; + query.OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)]; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return ConvertToResult(_libraryManager.GetItemList(query)); } private QueryResult<BaseItem> GetMovieResume(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; + query.OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)]; query.IsResumable = true; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -247,7 +245,7 @@ namespace MediaBrowser.Controller.Entities { var genres = parent.QueryRecursive(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { BaseItemKind.Movie }, + IncludeItemTypes = [BaseItemKind.Movie], Recursive = true, EnableTotalRecordCount = false }).Items @@ -275,10 +273,10 @@ namespace MediaBrowser.Controller.Entities { query.Recursive = true; query.Parent = queryParent; - query.GenreIds = new[] { displayParent.Id }; + query.GenreIds = [displayParent.Id]; query.SetUser(user); - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return _libraryManager.GetItemsResult(query); } @@ -292,12 +290,12 @@ namespace MediaBrowser.Controller.Entities if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] - { + query.IncludeItemTypes = + [ BaseItemKind.Series, BaseItemKind.Season, BaseItemKind.Episode - }; + ]; } return parent.QueryRecursive(query); @@ -319,12 +317,12 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetTvLatest(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; + query.OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)]; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { BaseItemKind.Episode }; + query.IncludeItemTypes = [BaseItemKind.Episode]; query.IsVirtualItem = false; return ConvertToResult(_libraryManager.GetItemList(query)); @@ -332,7 +330,7 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetTvNextUp(Folder parent, InternalItemsQuery query) { - var parentFolders = GetMediaFolders(parent, query.User, new[] { CollectionType.tvshows }); + var parentFolders = GetMediaFolders(parent, query.User, [CollectionType.tvshows]); var result = _tvSeriesManager.GetNextUp( new NextUpQuery @@ -349,13 +347,13 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetTvResume(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; + query.OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)]; query.IsResumable = true; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { BaseItemKind.Episode }; + query.IncludeItemTypes = [BaseItemKind.Episode]; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -366,7 +364,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { BaseItemKind.Series }; + query.IncludeItemTypes = [BaseItemKind.Series]; return _libraryManager.GetItemsResult(query); } @@ -375,7 +373,7 @@ namespace MediaBrowser.Controller.Entities { var genres = parent.QueryRecursive(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { BaseItemKind.Series }, + IncludeItemTypes = [BaseItemKind.Series], Recursive = true, EnableTotalRecordCount = false }).Items @@ -403,10 +401,10 @@ namespace MediaBrowser.Controller.Entities { query.Recursive = true; query.Parent = queryParent; - query.GenreIds = new[] { displayParent.Id }; + query.GenreIds = [displayParent.Id]; query.SetUser(user); - query.IncludeItemTypes = new[] { BaseItemKind.Series }; + query.IncludeItemTypes = [BaseItemKind.Series]; return _libraryManager.GetItemsResult(query); } @@ -418,7 +416,7 @@ namespace MediaBrowser.Controller.Entities { items = items.Where(i => Filter(i, query.User, query, _userDataManager, _libraryManager)); - return PostFilterAndSort(items, null, query, _libraryManager); + return SortAndPage(items, null, query, _libraryManager); } public static bool FilterItem(BaseItem item, InternalItemsQuery query) @@ -426,21 +424,6 @@ namespace MediaBrowser.Controller.Entities return Filter(item, query.User, query, BaseItem.UserDataManager, BaseItem.LibraryManager); } - public static QueryResult<BaseItem> PostFilterAndSort( - IEnumerable<BaseItem> items, - int? totalRecordLimit, - InternalItemsQuery query, - ILibraryManager libraryManager) - { - // This must be the last filter - if (!query.AdjacentTo.IsNullOrEmpty()) - { - items = FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); - } - - return SortAndPage(items, totalRecordLimit, query, libraryManager); - } - public static QueryResult<BaseItem> SortAndPage( IEnumerable<BaseItem> items, int? totalRecordLimit, @@ -556,38 +539,6 @@ namespace MediaBrowser.Controller.Entities } } - if (query.IsPlayed.HasValue) - { - userData ??= userDataManager.GetUserData(user, item); - if (item.IsPlayed(user, userData) != query.IsPlayed.Value) - { - return false; - } - } - - // Filter by Video3DFormat - if (query.Is3D.HasValue) - { - var val = query.Is3D.Value; - var video = item as Video; - - if (video is null || val != video.Video3DFormat.HasValue) - { - return false; - } - } - - /* - * fuck - fix this - if (query.IsHD.HasValue) - { - if (item.IsHD != query.IsHD.Value) - { - return false; - } - } - */ - if (query.IsLocked.HasValue) { var val = query.IsLocked.Value; @@ -645,68 +596,6 @@ namespace MediaBrowser.Controller.Entities } } - if (query.HasOfficialRating.HasValue) - { - var filterValue = query.HasOfficialRating.Value; - - var hasValue = !string.IsNullOrEmpty(item.OfficialRating); - - if (hasValue != filterValue) - { - return false; - } - } - - if (query.IsPlaceHolder.HasValue) - { - var filterValue = query.IsPlaceHolder.Value; - - var isPlaceHolder = false; - - if (item is ISupportsPlaceHolders hasPlaceHolder) - { - isPlaceHolder = hasPlaceHolder.IsPlaceHolder; - } - - if (isPlaceHolder != filterValue) - { - return false; - } - } - - if (query.HasSpecialFeature.HasValue) - { - var filterValue = query.HasSpecialFeature.Value; - - if (item is IHasSpecialFeatures movie) - { - var ok = filterValue - ? movie.SpecialFeatureIds.Count > 0 - : movie.SpecialFeatureIds.Count == 0; - - if (!ok) - { - return false; - } - } - else - { - return false; - } - } - - if (query.HasSubtitles.HasValue) - { - var val = query.HasSubtitles.Value; - - var video = item as Video; - - if (video is null || val != video.HasSubtitles) - { - return false; - } - } - if (query.HasParentalRating.HasValue) { var val = query.HasParentalRating.Value; @@ -734,66 +623,12 @@ namespace MediaBrowser.Controller.Entities } } - if (query.HasTrailer.HasValue) - { - var val = query.HasTrailer.Value; - var trailerCount = 0; - - if (item is IHasTrailers hasTrailers) - { - trailerCount = hasTrailers.GetTrailerCount(); - } - - var ok = val ? trailerCount > 0 : trailerCount == 0; - - if (!ok) - { - return false; - } - } - - if (query.HasThemeSong.HasValue) - { - var filterValue = query.HasThemeSong.Value; - - var themeCount = item.GetThemeSongs(user).Count; - var ok = filterValue ? themeCount > 0 : themeCount == 0; - - if (!ok) - { - return false; - } - } - - if (query.HasThemeVideo.HasValue) - { - var filterValue = query.HasThemeVideo.Value; - - var themeCount = item.GetThemeVideos(user).Count; - var ok = filterValue ? themeCount > 0 : themeCount == 0; - - if (!ok) - { - return false; - } - } - // Apply genre filter if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparison.OrdinalIgnoreCase))) { return false; } - // Filter by VideoType - if (query.VideoTypes.Length > 0) - { - var video = item as Video; - if (video is null || !query.VideoTypes.Contains(video.VideoType)) - { - return false; - } - } - if (query.ImageTypes.Length > 0 && !query.ImageTypes.Any(item.HasImage)) { return false; @@ -912,30 +747,6 @@ namespace MediaBrowser.Controller.Entities } } - if (query.SeriesStatuses.Length > 0) - { - var ok = new[] { item }.OfType<Series>().Any(p => p.Status.HasValue && query.SeriesStatuses.Contains(p.Status.Value)); - if (!ok) - { - return false; - } - } - - if (query.AiredDuringSeason.HasValue) - { - var episode = item as Episode; - - if (episode is null) - { - return false; - } - - if (!Series.FilterEpisodesBySeason(new[] { episode }, query.AiredDuringSeason.Value, true).Any()) - { - return false; - } - } - if (query.ExcludeItemIds.Contains(item.Id)) { return false; @@ -989,7 +800,7 @@ namespace MediaBrowser.Controller.Entities return GetMediaFolders(user, viewTypes); } - return new BaseItem[] { parent }; + return [parent]; } private UserView GetUserViewWithName(CollectionType? type, string sortName, BaseItem parent) diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 1043029c6e..1ddc193359 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -160,7 +160,7 @@ namespace MediaBrowser.Controller.Entities public bool IsStacked => AdditionalParts.Length > 0; [JsonIgnore] - public override bool HasLocalAlternateVersions => LocalAlternateVersions.Length > 0; + public override bool HasLocalAlternateVersions => LibraryManager.GetLocalAlternateVersionIds(this).Any(); public static IRecordingsManager RecordingsManager { get; set; } @@ -260,7 +260,10 @@ namespace MediaBrowser.Controller.Entities { if (callstack.Contains(video.Id)) { - return video.LinkedAlternateVersions.Length + video.LocalAlternateVersions.Length + 1; + // Count alternate versions using LibraryManager + var linkedCount = LibraryManager.GetLinkedAlternateVersions(video).Count(); + var localCount = LibraryManager.GetLocalAlternateVersionIds(video).Count(); + return linkedCount + localCount + 1; } callstack.Add(video.Id); @@ -268,7 +271,10 @@ namespace MediaBrowser.Controller.Entities } } - return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1; + // Count alternate versions using LibraryManager + var linkedVersionCount = LibraryManager.GetLinkedAlternateVersions(this).Count(); + var localVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count(); + return linkedVersionCount + localVersionCount + 1; } public override List<string> GetUserDataKeys() @@ -364,11 +370,6 @@ namespace MediaBrowser.Controller.Entities return AdditionalParts.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); } - public IEnumerable<Guid> GetLocalAlternateVersionIds() - { - return LocalAlternateVersions.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); - } - private string GetUserDataKey(string providerId) { var key = providerId + "-" + ExtraType.ToString().ToLowerInvariant(); @@ -382,15 +383,6 @@ namespace MediaBrowser.Controller.Entities return key; } - public IEnumerable<Video> GetLinkedAlternateVersions() - { - return LinkedAlternateVersions - .Select(GetLinkedChild) - .Where(i => i is not null) - .OfType<Video>() - .OrderBy(i => i.SortName); - } - /// <summary> /// Gets the additional parts. /// </summary> @@ -454,7 +446,7 @@ namespace MediaBrowser.Controller.Entities RefreshLinkedAlternateVersions(); var tasks = LocalAlternateVersions - .Select(i => RefreshMetadataForOwnedVideo(options, false, i, cancellationToken)); + .Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken)); await Task.WhenAll(tasks).ConfigureAwait(false); } @@ -463,6 +455,39 @@ namespace MediaBrowser.Controller.Entities return hasChanges; } + protected virtual async Task RefreshMetadataForVersions(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) + { + await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false); + } + + private new async Task RefreshMetadataForOwnedVideo(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) + { + var newOptions = new MetadataRefreshOptions(options) + { + SearchResult = null + }; + + var id = LibraryManager.GetNewItemId(path, typeof(Video)); + if (LibraryManager.GetItemById(id) is not Video video) + { + video = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Video; + + newOptions.ForceSave = true; + } + + if (video is null) + { + return; + } + + if (video.OwnerId.IsEmpty()) + { + video.OwnerId = Id; + } + + await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); + } + private void RefreshLinkedAlternateVersions() { foreach (var child in LinkedAlternateVersions) @@ -480,7 +505,7 @@ namespace MediaBrowser.Controller.Entities { await base.UpdateToRepositoryAsync(updateReason, cancellationToken).ConfigureAwait(false); - var localAlternates = GetLocalAlternateVersionIds() + var localAlternates = LibraryManager.GetLocalAlternateVersionIds(this) .Select(i => LibraryManager.GetItemById(i)) .Where(i => i is not null); @@ -537,7 +562,7 @@ namespace MediaBrowser.Controller.Entities (this, MediaSourceType.Default) }; - list.AddRange(GetLinkedAlternateVersions().Select(i => ((BaseItem)i, MediaSourceType.Grouping))); + list.AddRange(LibraryManager.GetLinkedAlternateVersions(this).Select(i => ((BaseItem)i, MediaSourceType.Grouping))); if (!string.IsNullOrEmpty(PrimaryVersionId)) { @@ -545,14 +570,14 @@ namespace MediaBrowser.Controller.Entities { var existingIds = list.Select(i => i.Item1.Id).ToList(); list.Add((primary, MediaSourceType.Grouping)); - list.AddRange(primary.GetLinkedAlternateVersions().Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping))); + list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping))); } } var localAlternates = list .SelectMany(i => { - return i.Item1 is Video video ? video.GetLocalAlternateVersionIds() : Enumerable.Empty<Guid>(); + return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>(); }) .Select(LibraryManager.GetItemById) .Where(i => i is not null) diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index df1c98f3f7..c19d15d85f 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -214,6 +214,22 @@ namespace MediaBrowser.Controller.Library Task<IEnumerable<Video>> GetIntros(BaseItem item, User user); /// <summary> + /// Gets the IDs of local alternate versions for a video. + /// Local alternate versions are alternate quality versions at different file paths. + /// </summary> + /// <param name="video">The video item.</param> + /// <returns>Enumerable of alternate version item IDs.</returns> + IEnumerable<Guid> GetLocalAlternateVersionIds(Video video); + + /// <summary> + /// Gets the linked alternate versions for a video. + /// Linked alternate versions are different items representing the same content (e.g., Director's Cut). + /// </summary> + /// <param name="video">The video item.</param> + /// <returns>Enumerable of linked Video items.</returns> + IEnumerable<Video> GetLinkedAlternateVersions(Video video); + + /// <summary> /// Adds the parts. /// </summary> /// <param name="rules">The rules.</param> @@ -601,6 +617,20 @@ namespace MediaBrowser.Controller.Library IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents, DateTime dateCutoff); /// <summary> + /// Gets next up episodes for multiple series in a single batched query. + /// </summary> + /// <param name="query">The query filter.</param> + /// <param name="seriesKeys">The series presentation unique keys to query.</param> + /// <param name="includeSpecials">Whether to include specials for aired episode order sorting.</param> + /// <param name="includeWatchedForRewatching">Whether to include watched episodes for rewatching mode.</param> + /// <returns>A dictionary mapping series key to batch result.</returns> + IReadOnlyDictionary<string, MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery query, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching); + + /// <summary> /// Gets the items result. /// </summary> /// <param name="query">The query.</param> @@ -649,6 +679,23 @@ namespace MediaBrowser.Controller.Library ItemCounts GetItemCounts(InternalItemsQuery query); + /// <summary> + /// Batch-fetches child counts for multiple parent folders. + /// Returns the count of immediate children (non-recursive) for each parent. + /// </summary> + /// <param name="parentIds">The list of parent folder IDs.</param> + /// <param name="userId">The user ID for access filtering.</param> + /// <returns>Dictionary mapping parent ID to child count.</returns> + Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId); + + /// <summary> + /// Configures the query with user access settings including TopParentIds for library access. + /// Call this before passing a query to methods that need user access filtering. + /// </summary> + /// <param name="query">The query to configure.</param> + /// <param name="user">The user to configure access for.</param> + void ConfigureUserAccess(InternalItemsQuery query, User user); + Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason); BaseItem GetParentItem(Guid? parentId, Guid? userId); diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index bf80b7d0a8..f7ed39730e 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -88,6 +88,21 @@ public interface IItemRepository IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff); /// <summary> + /// Gets next up episodes for multiple series in a single batched query. + /// Returns the last watched episode, next unwatched episode, specials, and next played episode for each series. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <param name="seriesKeys">The series presentation unique keys to query.</param> + /// <param name="includeSpecials">Whether to include specials (ParentIndexNumber = 0) in the results.</param> + /// <param name="includeWatchedForRewatching">Whether to include watched episodes for rewatching mode.</param> + /// <returns>A dictionary mapping series key to batch result containing episodes needed for NextUp calculation.</returns> + IReadOnlyDictionary<string, NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery filter, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching); + + /// <summary> /// Updates the inherited values. /// </summary> void UpdateInheritedValues(); @@ -133,9 +148,66 @@ public interface IItemRepository bool GetIsPlayed(User user, Guid id, bool recursive); /// <summary> + /// Gets the count of played items that are descendants of the specified ancestor. + /// Uses the AncestorIds table for efficient recursive lookup. + /// Applies user access filtering (library access, parental controls, tags). + /// </summary> + /// <param name="filter">The query filter containing user access settings.</param> + /// <param name="ancestorId">The ancestor item id.</param> + /// <returns>The count of played descendant items.</returns> + int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId); + + /// <summary> + /// Gets the total count of items that are descendants of the specified ancestor. + /// Uses the AncestorIds table for efficient recursive lookup. + /// Applies user access filtering (library access, parental controls, tags). + /// </summary> + /// <param name="filter">The query filter containing user access settings.</param> + /// <param name="ancestorId">The ancestor item id.</param> + /// <returns>The total count of descendant items.</returns> + int GetTotalCount(InternalItemsQuery filter, Guid ancestorId); + + /// <summary> + /// Gets both the played count and total count of items that are descendants of the specified ancestor. + /// Uses the AncestorIds table for efficient recursive lookup. + /// Applies user access filtering (library access, parental controls, tags). + /// </summary> + /// <param name="filter">The query filter containing user access settings.</param> + /// <param name="ancestorId">The ancestor item id.</param> + /// <returns>A tuple containing (Played count, Total count).</returns> + (int Played, int Total) GetPlayedAndTotalCount(InternalItemsQuery filter, Guid ancestorId); + + /// <summary> + /// Gets both the played count and total count of items that are linked children of the specified parent. + /// Uses the LinkedChildren table for BoxSets, Playlists, etc. + /// Applies user access filtering (library access, parental controls, tags). + /// </summary> + /// <param name="filter">The query filter containing user access settings.</param> + /// <param name="parentId">The parent item id (BoxSet, Playlist, etc.).</param> + /// <returns>A tuple containing (Played count, Total count).</returns> + (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId); + + /// <summary> + /// Gets the IDs of linked children for the specified parent. + /// </summary> + /// <param name="parentId">The parent item ID.</param> + /// <param name="childType">Optional child type filter (e.g., LocalAlternateVersion, LinkedAlternateVersion).</param> + /// <returns>List of child item IDs.</returns> + IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null); + + /// <summary> /// Gets all artist matches from the db. /// </summary> /// <param name="artistNames">The names of the artists.</param> /// <returns>A map of the artist name and the potential matches.</returns> IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames); + + /// <summary> + /// Batch-fetches child counts for multiple parent folders. + /// Returns the count of immediate children (non-recursive) for each parent. + /// </summary> + /// <param name="parentIds">The list of parent folder IDs.</param> + /// <param name="userId">The user ID for access filtering.</param> + /// <returns>Dictionary mapping parent ID to child count.</returns> + Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId); } diff --git a/MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs b/MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs new file mode 100644 index 0000000000..f5b09498b9 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Result of a batched NextUp query for a single series. +/// </summary> +public sealed class NextUpEpisodeBatchResult +{ + /// <summary> + /// Gets or sets the last watched episode (highest season/episode that is played). + /// </summary> + public BaseItem? LastWatched { get; set; } + + /// <summary> + /// Gets or sets the next unwatched episode after the last watched position. + /// </summary> + public BaseItem? NextUp { get; set; } + + /// <summary> + /// Gets or sets specials that may air between episodes. + /// Only populated when includeSpecials is true. + /// </summary> + public IReadOnlyList<BaseItem>? Specials { get; set; } + + /// <summary> + /// Gets or sets the last watched episode for rewatching mode (most recently played). + /// Only populated when includeWatchedForRewatching is true. + /// </summary> + public BaseItem? LastWatchedForRewatching { get; set; } + + /// <summary> + /// Gets or sets the next played episode for rewatching mode. + /// Only populated when includeWatchedForRewatching is true. + /// </summary> + public BaseItem? NextPlayedForRewatching { get; set; } +} diff --git a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs index 119effe791..cf1423d02d 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs @@ -780,7 +780,8 @@ namespace MediaBrowser.LocalMetadata.Parsers } /// <summary> - /// Get linked child. + /// Get linked child from XML. Uses deprecated Path/LibraryItemId properties for backward compatibility + /// with existing XML files. These will be resolved to ItemId when the linked child is accessed. /// </summary> /// <param name="reader">The xml reader.</param> /// <returns>The linked child.</returns> @@ -791,6 +792,7 @@ namespace MediaBrowser.LocalMetadata.Parsers reader.MoveToContent(); reader.Read(); +#pragma warning disable CS0618 // Type or member is obsolete - reading legacy XML format for backward compatibility // Loop through each element while (!reader.EOF && reader.ReadState == ReadState.Interactive) { @@ -820,6 +822,7 @@ namespace MediaBrowser.LocalMetadata.Parsers { return linkedItem; } +#pragma warning restore CS0618 return null; } diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index 025a815247..a065b68321 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -467,41 +467,40 @@ namespace MediaBrowser.LocalMetadata.Savers } /// <summary> - /// ADd linked children. + /// Add linked children. /// </summary> /// <param name="item">The item.</param> /// <param name="writer">The xml writer.</param> /// <param name="pluralNodeName">The plural node name.</param> /// <param name="singularNodeName">The singular node name.</param> /// <returns>The task object representing the asynchronous operation.</returns> - private static async Task AddLinkedChildren(Folder item, XmlWriter writer, string pluralNodeName, string singularNodeName) + private async Task AddLinkedChildren(Folder item, XmlWriter writer, string pluralNodeName, string singularNodeName) { - var items = item.LinkedChildren + var linkedChildren = item.LinkedChildren .Where(i => i.Type == LinkedChildType.Manual) .ToList(); - if (items.Count == 0) + if (linkedChildren.Count == 0) { return; } await writer.WriteStartElementAsync(null, pluralNodeName, null).ConfigureAwait(false); - foreach (var link in items) + foreach (var link in linkedChildren) { - if (!string.IsNullOrWhiteSpace(link.Path) || !string.IsNullOrWhiteSpace(link.LibraryItemId)) + // Resolve ItemId to get the item's path for XML portability + string? path = null; + if (link.ItemId.HasValue && !link.ItemId.Value.Equals(Guid.Empty)) { - await writer.WriteStartElementAsync(null, singularNodeName, null).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(link.Path)) - { - await writer.WriteElementStringAsync(null, "Path", null, link.Path).ConfigureAwait(false); - } - - if (!string.IsNullOrWhiteSpace(link.LibraryItemId)) - { - await writer.WriteElementStringAsync(null, "ItemId", null, link.LibraryItemId).ConfigureAwait(false); - } + var linkedItem = LibraryManager.GetItemById(link.ItemId.Value); + path = linkedItem?.Path; + } + if (!string.IsNullOrWhiteSpace(path)) + { + await writer.WriteStartElementAsync(null, singularNodeName, null).ConfigureAwait(false); + await writer.WriteElementStringAsync(null, "Path", null, path).ConfigureAwait(false); await writer.WriteEndElementAsync().ConfigureAwait(false); } } diff --git a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs index eccf8a606d..bdc5b5df29 100644 --- a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs +++ b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs @@ -70,7 +70,7 @@ public class BoxSetMetadataService : MetadataService<BoxSet, BoxSetInfo> if (mergeMetadataSettings) { // TODO: Change to only replace when currently empty or requested. This is currently not done because the metadata service is not handling attaching collection items based on the provider responses - targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray(); + targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.ItemId).ToArray(); } } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index f8e2aece1f..aad9ca2844 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -31,6 +31,7 @@ using MediaBrowser.Model.Extensions; using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; using MediaBrowser.Model.Providers; +using MediaBrowser.Model.Querying; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Book = MediaBrowser.Controller.Entities.Book; @@ -69,6 +70,13 @@ namespace MediaBrowser.Providers.Manager o.PoolInitialFill = 1; }); + /// <summary> + /// Cache for ordered metadata providers per library/item type combination. + /// Key: (LibraryPath, ItemTypeName, IncludeDisabled, ForceEnableInternetMetadata). + /// Value: Array of ordered metadata providers (before per-item filtering). + /// </summary> + private readonly ConcurrentDictionary<MetadataProviderCacheKey, IMetadataProvider[]> _metadataProviderCache = new(); + private IImageProvider[] _imageProviders = []; private IMetadataService[] _metadataServices = []; private IMetadataProvider[] _metadataProviders = []; @@ -119,6 +127,8 @@ namespace MediaBrowser.Providers.Manager _lyricManager = lyricManager; _memoryCache = memoryCache; _mediaSegmentManager = mediaSegmentManager; + + CollectionFolder.LibraryOptionsUpdated += OnLibraryOptionsUpdated; } /// <inheritdoc/> @@ -427,8 +437,37 @@ namespace MediaBrowser.Providers.Manager where T : BaseItem { var globalMetadataOptions = GetMetadataOptions(item); + var libraryPath = GetLibraryPathForItem(item); + + return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, false, false, libraryPath); + } + + /// <summary> + /// Gets metadata providers for the specified item. + /// </summary> + /// <typeparam name="T">The item type.</typeparam> + /// <param name="item">The item.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="includeDisabled">Whether to include disabled providers.</param> + /// <returns>The metadata providers.</returns> + public IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions, bool includeDisabled) + where T : BaseItem + { + var globalMetadataOptions = GetMetadataOptions(item); + var libraryPath = GetLibraryPathForItem(item); - return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, false, false); + return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, includeDisabled, false, libraryPath); + } + + private static string GetLibraryPathForItem(BaseItem item) + { + if (item is CollectionFolder collectionFolder) + { + return collectionFolder.Path ?? string.Empty; + } + + var topParent = item.GetTopParent(); + return topParent?.Path ?? string.Empty; } /// <inheritdoc /> @@ -437,15 +476,37 @@ namespace MediaBrowser.Providers.Manager return _savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, ItemUpdateType.MetadataEdit, false)); } - private IEnumerable<IMetadataProvider<T>> GetMetadataProvidersInternal<T>(BaseItem item, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions, bool includeDisabled, bool forceEnableInternetMetadata) + private IEnumerable<IMetadataProvider<T>> GetMetadataProvidersInternal<T>(BaseItem item, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions, bool includeDisabled, bool forceEnableInternetMetadata, string libraryPath) where T : BaseItem { - var localMetadataReaderOrder = libraryOptions.LocalMetadataReaderOrder ?? globalMetadataOptions.LocalMetadataReaderOrder; var typeOptions = libraryOptions.GetTypeOptions(item.GetType().Name); + + var orderedProviders = GetOrCreateOrderedProviders<T>(item.GetType().Name, libraryOptions, globalMetadataOptions, includeDisabled, forceEnableInternetMetadata, libraryPath); + + return orderedProviders.Where(i => CanRefreshMetadata(i, item, typeOptions, includeDisabled, forceEnableInternetMetadata)); + } + + private IMetadataProvider<T>[] GetOrCreateOrderedProviders<T>( + string itemTypeName, + LibraryOptions libraryOptions, + MetadataOptions globalMetadataOptions, + bool includeDisabled, + bool forceEnableInternetMetadata, + string libraryPath) + where T : BaseItem + { + var cacheKey = new MetadataProviderCacheKey(libraryPath, itemTypeName, includeDisabled, forceEnableInternetMetadata); + if (_metadataProviderCache.TryGetValue(cacheKey, out var cachedProviders)) + { + return cachedProviders.OfType<IMetadataProvider<T>>().ToArray(); + } + + var localMetadataReaderOrder = libraryOptions.LocalMetadataReaderOrder ?? globalMetadataOptions.LocalMetadataReaderOrder; + var typeOptions = libraryOptions.GetTypeOptions(itemTypeName); var metadataFetcherOrder = typeOptions?.MetadataFetcherOrder ?? globalMetadataOptions.MetadataFetcherOrder; - return _metadataProviders.OfType<IMetadataProvider<T>>() - .Where(i => CanRefreshMetadata(i, item, typeOptions, includeDisabled, forceEnableInternetMetadata)) + var orderedProviders = _metadataProviders.OfType<IMetadataProvider<T>>() + .Where(i => CanRefreshMetadataForCache(i, typeOptions, includeDisabled, forceEnableInternetMetadata)) .OrderBy(i => // local and remote providers will be interleaved in the final order // only relative order within a type matters: consumers of the list filter to one or the other @@ -456,7 +517,36 @@ namespace MediaBrowser.Providers.Manager // Default to end _ => int.MaxValue }) - .ThenBy(GetDefaultOrder); + .ThenBy(GetDefaultOrder) + .ToArray(); + + _metadataProviderCache.TryAdd(cacheKey, orderedProviders.Cast<IMetadataProvider>().ToArray()); + + return orderedProviders; + } + + private static bool CanRefreshMetadataForCache( + IMetadataProvider provider, + TypeOptions? libraryTypeOptions, + bool includeDisabled, + bool forceEnableInternetMetadata) + { + if (includeDisabled) + { + return true; + } + + if (forceEnableInternetMetadata || provider is not IRemoteMetadataProvider) + { + return true; + } + + if (libraryTypeOptions?.MetadataFetchers is { Length: > 0 } metadataFetchers) + { + return metadataFetchers.Contains(provider.Name, StringComparer.OrdinalIgnoreCase); + } + + return true; } private bool CanRefreshMetadata( @@ -607,7 +697,8 @@ namespace MediaBrowser.Providers.Manager private void AddMetadataPlugins<T>(List<MetadataPlugin> list, T item, LibraryOptions libraryOptions, MetadataOptions options) where T : BaseItem { - var providers = GetMetadataProvidersInternal<T>(item, libraryOptions, options, true, true).ToList(); + var libraryPath = GetLibraryPathForItem(item); + var providers = GetMetadataProvidersInternal<T>(item, libraryOptions, options, true, true, libraryPath).ToList(); // Locals list.AddRange(providers.Where(i => i is ILocalMetadataProvider).Select(i => new MetadataPlugin @@ -824,8 +915,8 @@ namespace MediaBrowser.Providers.Manager } var options = GetMetadataOptions(referenceItem); - - var providers = GetMetadataProvidersInternal<TItemType>(referenceItem, libraryOptions, options, searchInfo.IncludeDisabledProviders, false) + var libraryPath = GetLibraryPathForItem(referenceItem); + var providers = GetMetadataProvidersInternal<TItemType>(referenceItem, libraryOptions, options, searchInfo.IncludeDisabledProviders, false, libraryPath) .OfType<IRemoteSearchProvider<TLookupType>>(); if (!string.IsNullOrEmpty(searchInfo.SearchProviderName)) @@ -1157,6 +1248,8 @@ namespace MediaBrowser.Providers.Manager if (disposing) { + CollectionFolder.LibraryOptionsUpdated -= OnLibraryOptionsUpdated; + if (!_disposeCancellationTokenSource.IsCancellationRequested) { _disposeCancellationTokenSource.Cancel(); @@ -1168,5 +1261,38 @@ namespace MediaBrowser.Providers.Manager _disposed = true; } + + private void OnLibraryOptionsUpdated(object? sender, LibraryOptionsUpdatedEventArgs e) + { + var keysToRemove = _metadataProviderCache.Keys + .Where(k => string.Equals(k.LibraryPath, e.LibraryPath, StringComparison.Ordinal)) + .ToList(); + + foreach (var key in keysToRemove) + { + _metadataProviderCache.TryRemove(key, out _); + } + + _logger.LogDebug("Invalidated metadata provider cache for library: {LibraryPath}", e.LibraryPath); + } + + internal void ClearMetadataProviderCache() + { + _metadataProviderCache.Clear(); + _logger.LogDebug("Cleared entire metadata provider cache"); + } + + /// <summary> + /// Cache key for metadata provider lookups. + /// </summary> + /// <param name="LibraryPath">The library path for the collection folder.</param> + /// <param name="ItemTypeName">The item type name.</param> + /// <param name="IncludeDisabled">Whether to include disabled providers.</param> + /// <param name="ForceEnableInternetMetadata">Whether internet metadata is force-enabled.</param> + private readonly record struct MetadataProviderCacheKey( + string LibraryPath, + string ItemTypeName, + bool IncludeDisabled, + bool ForceEnableInternetMetadata); } } diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index 4c10fe3f1a..924fde4808 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs @@ -175,11 +175,11 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, private LinkedChild GetLinkedChild(string itemPath, string playlistPath, List<string> libraryRoots) { - if (TryGetPlaylistItemPath(itemPath, playlistPath, libraryRoots, out var parsedPath)) + if (TryResolvePlaylistItem(itemPath, playlistPath, libraryRoots, out var item)) { return new LinkedChild { - Path = parsedPath, + ItemId = item.Id, Type = LinkedChildType.Manual }; } @@ -187,9 +187,9 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, return null; } - private bool TryGetPlaylistItemPath(string itemPath, string playlistPath, List<string> libraryPaths, out string path) + private bool TryResolvePlaylistItem(string itemPath, string playlistPath, List<string> libraryPaths, out BaseItem item) { - path = null; + item = null; string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath); if (!File.Exists(pathToCheck)) { @@ -200,8 +200,8 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, { if (pathToCheck.StartsWith(libraryPath, StringComparison.OrdinalIgnoreCase)) { - path = pathToCheck; - return true; + item = _libraryManager.FindByPath(pathToCheck, null); + return item is not null; } } diff --git a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs index e0a4c4f320..45b61319b7 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs @@ -72,7 +72,7 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo> } else { - targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray(); + targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.ItemId).ToArray(); } if (replaceData || targetItem.Shares.Count == 0) diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs index 0217bded13..fe596e2b75 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -781,26 +781,30 @@ namespace MediaBrowser.XbmcMetadata.Savers private void AddCollectionItems(Folder item, XmlWriter writer) { - var items = item.LinkedChildren + var linkedChildren = item.LinkedChildren .Where(i => i.Type == LinkedChildType.Manual) - .OrderBy(i => i.Path?.Trim()) - .ThenBy(i => i.LibraryItemId?.Trim()) .ToList(); - foreach (var link in items) - { - writer.WriteStartElement("collectionitem"); - - if (!string.IsNullOrWhiteSpace(link.Path)) + // Resolve ItemIds to paths and sort + var itemsWithPaths = linkedChildren + .Select(link => { - writer.WriteElementString("path", link.Path); - } + if (link.ItemId.HasValue && !link.ItemId.Value.Equals(Guid.Empty)) + { + var linkedItem = LibraryManager.GetItemById(link.ItemId.Value); + return linkedItem?.Path; + } - if (!string.IsNullOrWhiteSpace(link.LibraryItemId)) - { - writer.WriteElementString("ItemId", link.LibraryItemId); - } + return null; + }) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .OrderBy(path => path?.Trim()) + .ToList(); + foreach (var path in itemsWithPaths) + { + writer.WriteStartElement("collectionitem"); + writer.WriteElementString("path", path); writer.WriteEndElement(); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs index 27dbeaba6a..f52a68c684 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs @@ -18,6 +18,12 @@ public interface IJellyfinDatabaseProvider IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; } /// <summary> + /// Gets the descendant query provider for this database type. + /// Used for recursive CTE queries to find all descendants of an item. + /// </summary> + IDescendantQueryProvider DescendantQueryProvider { get; } + + /// <summary> /// Initialises jellyfins EFCore database access. /// </summary> /// <param name="options">The EFCore database options.</param> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index da63df8e29..94c470e6cb 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -40,6 +40,9 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; } /// <inheritdoc/> + public IDescendantQueryProvider DescendantQueryProvider { get; } = new SqliteDescendantQueryProvider(); + + /// <inheritdoc/> public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration) { static T? GetOption<T>(ICollection<CustomDatabaseOption>? options, string key, Func<string, T> converter, Func<T>? defaultValue = null) |
