aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-01-17 17:10:07 +0100
committerShadowghost <Ghost_of_Stone@web.de>2026-01-18 19:48:46 +0100
commit5996c4afce11249804d24f1caa3a99b390543c4d (patch)
treed84b98428d95c801492b1354571e2ab3fc0cc99b
parentdfa78590c2899c7e74b142ebbced4140a354aed0 (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
-rw-r--r--Emby.Server.Implementations/Collections/CollectionManager.cs4
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs96
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs119
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs20
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs1
-rw-r--r--Emby.Server.Implementations/TV/TVSeriesManager.cs205
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs37
-rw-r--r--Jellyfin.Api/Controllers/VideosController.cs10
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs1608
-rw-r--r--MediaBrowser.Controller/Dto/IDtoService.cs3
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs19
-rw-r--r--MediaBrowser.Controller/Entities/CollectionFolder.cs7
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs201
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs4
-rw-r--r--MediaBrowser.Controller/Entities/LinkedChild.cs20
-rw-r--r--MediaBrowser.Controller/Entities/LinkedChildComparer.cs23
-rw-r--r--MediaBrowser.Controller/Entities/LinkedChildType.cs12
-rw-r--r--MediaBrowser.Controller/Entities/Movies/BoxSet.cs4
-rw-r--r--MediaBrowser.Controller/Entities/Movies/Movie.cs36
-rw-r--r--MediaBrowser.Controller/Entities/TV/Episode.cs4
-rw-r--r--MediaBrowser.Controller/Entities/TV/Series.cs4
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs243
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs69
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs47
-rw-r--r--MediaBrowser.Controller/Persistence/IItemRepository.cs72
-rw-r--r--MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs38
-rw-r--r--MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs5
-rw-r--r--MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs31
-rw-r--r--MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs2
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs144
-rw-r--r--MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs12
-rw-r--r--MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs2
-rw-r--r--MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs32
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs6
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs3
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)