From 79a55327dcb3899fb85147f7ec6b19cd71e5dcfb Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 27 Jul 2026 11:48:15 +0200 Subject: Fix extras naming and version assignment --- MediaBrowser.Controller/Entities/BaseItem.cs | 52 +++++++++++++++---- MediaBrowser.Controller/Entities/Video.cs | 74 ++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 10 deletions(-) (limited to 'MediaBrowser.Controller/Entities') diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 209feac702..9c6d18d509 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -88,7 +88,7 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.Short }; - private static readonly char[] VersionDelimiters = ['-', '_', '.']; + private protected static readonly char[] VersionDelimiters = ['-', '_', '.']; private string _sortName; @@ -1543,19 +1543,33 @@ namespace MediaBrowser.Controller.Entities private async Task RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList fileSystemChildren, CancellationToken cancellationToken) { - var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); - var newExtraIds = Array.ConvertAll(extras, x => x.Id); - + // An extra is owned by the version it is named after, so all of them are maintained together. var currentExtras = LibraryManager.GetItemList(new InternalItemsQuery() { - OwnerIds = [item.Id] - }); + OwnerIds = item.GetOwnedVersionIds() + }).Where(e => e.ExtraType.HasValue).ToList(); var currentExtraIds = currentExtras.Select(e => e.Id).ToArray(); + // Snapshot the persisted names before resolving, as FindExtras corrects the name on the + // items it hands back and may well hand back these very instances. + var currentExtraNames = new Dictionary(); + foreach (var extra in currentExtras) + { + currentExtraNames[extra.Id] = extra.Name; + } + + var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); + var newExtraIds = Array.ConvertAll(extras, x => x.Id); + + var renamedExtraIds = extras + .Where(e => currentExtraNames.TryGetValue(e.Id, out var oldName) && !string.Equals(oldName, e.Name, StringComparison.Ordinal)) + .Select(e => e.Id) + .ToHashSet(); + var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x)); - if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) + if (!extrasChanged && renamedExtraIds.Count == 0 && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) { // The owner's dates may only have become known after its extras were created, so keep // them in sync even when there is nothing to refresh. @@ -1570,12 +1584,11 @@ namespace MediaBrowser.Controller.Entities return false; } - var ownerId = item.Id; - var tasks = extras.Select(i => { + var ownerId = item.GetOwnerIdForExtra(i); var subOptions = new MetadataRefreshOptions(options); - if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty()) + if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty() || renamedExtraIds.Contains(i.Id)) { subOptions.ForceSave = true; } @@ -2920,6 +2933,25 @@ namespace MediaBrowser.Controller.Entities return [Id]; } + /// + /// Gets the ids of this item and the versions of it whose extras it maintains. + /// + /// An array containing the version ids. + protected virtual Guid[] GetOwnedVersionIds() + { + return [Id]; + } + + /// + /// Gets the id of the version an extra belongs to. + /// + /// The extra. + /// The id of the owning version. + protected virtual Guid GetOwnerIdForExtra(BaseItem extra) + { + return Id; + } + /// /// Get all extras associated with this item, sorted by . /// diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 0606fe1870..5012378c52 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -751,6 +751,80 @@ namespace MediaBrowser.Controller.Entities .ToArray(); } + /// + protected override Guid[] GetOwnedVersionIds() + { + // Only the versions that live beside this one in the folder this scan covers. Linked + // versions are items of their own and maintain their extras themselves. + return [Id, .. LibraryManager.GetLocalAlternateVersionIds(this)]; + } + + /// + protected override Guid GetOwnerIdForExtra(BaseItem extra) + { + if (string.IsNullOrEmpty(extra.Path)) + { + return Id; + } + + var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path.AsSpan()); + var extraFileName = System.IO.Path.GetFileNameWithoutExtension(extra.Path.AsSpan()); + + var ownerId = Id; + var matchedLength = MatchedVersionNameLength(Path, extraDirectory, extraFileName); + + foreach (var versionId in LibraryManager.GetLocalAlternateVersionIds(this)) + { + var version = LibraryManager.GetItemById(versionId); + if (version is null) + { + continue; + } + + // "Movie - [2160p]-trailer.mkv" belongs to "Movie - [2160p].mkv" rather than to the + // primary version, whose name it also starts with when the primary is plain "Movie.mkv" + var length = MatchedVersionNameLength(version.Path, extraDirectory, extraFileName); + if (length > matchedLength) + { + matchedLength = length; + ownerId = versionId; + } + } + + return ownerId; + } + + /// + /// Gets how much of an extra's file name is the name of the given version file, or 0 when the + /// extra is not named after it. + /// + /// The path of the version. + /// The directory the extra lives in. + /// The file name of the extra, without extension. + /// The length of the match. + private static int MatchedVersionNameLength(string versionPath, ReadOnlySpan extraDirectory, ReadOnlySpan extraFileName) + { + if (string.IsNullOrEmpty(versionPath) + || !System.IO.Path.GetDirectoryName(versionPath.AsSpan()).Equals(extraDirectory, StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + var versionFileName = System.IO.Path.GetFileNameWithoutExtension(versionPath.AsSpan()); + if (versionFileName.IsEmpty || !extraFileName.StartsWith(versionFileName, StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + // The version name has to end where the extra's own name begins, so that a version + // named "Movie - 4K" does not claim the extras of "Movie - 4Kish" + var remainder = extraFileName[versionFileName.Length..]; + + return !remainder.IsEmpty && (remainder[0] == ' ' || Array.IndexOf(VersionDelimiters, remainder[0]) >= 0) + ? versionFileName.Length + : 0; + } + protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() { var primary = PrimaryVersionId.HasValue -- cgit v1.2.3 From d8fc0a991433bd6ec9688ce16f944e21671fc945 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 29 Jul 2026 13:13:44 +0200 Subject: Fix AdjacentTo being ignored on non-recursive item queries --- .../Item/BaseItemRepository.QueryBuilding.cs | 29 ++++++++++++++++ .../Item/BaseItemRepository.Querying.cs | 2 ++ .../Item/BaseItemRepository.TranslateQuery.cs | 27 --------------- MediaBrowser.Controller/Entities/Folder.cs | 10 +----- .../Entities/UserViewBuilder.cs | 39 ++++++++++++++-------- 5 files changed, 58 insertions(+), 49 deletions(-) (limited to 'MediaBrowser.Controller/Entities') diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index a4de9feb05..54926b7cd9 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -35,11 +35,40 @@ public sealed partial class BaseItemRepository { dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyNavigations(dbQuery, filter); return dbQuery; } + /// + /// Trims an ordered query down to the AdjacentTo item and its immediate neighbours. + /// + private IQueryable ApplyAdjacencyFilter(JellyfinDbContext context, IQueryable dbQuery, InternalItemsQuery filter) + { + if (filter.AdjacentTo.IsNullOrEmpty()) + { + return dbQuery; + } + + // Adjacency is relative to the result set and the order the query asked for, so the ids have + // to be read back in that order. + var orderedIds = dbQuery.Select(e => e.Id).ToList(); + var index = orderedIds.IndexOf(filter.AdjacentTo.Value); + if (index < 0) + { + // The item isn't part of this result set, so it has no neighbours in it either. + return dbQuery.Take(0); + } + + var start = Math.Max(index - 1, 0); + var adjacentIds = orderedIds.GetRange(start, Math.Min(index + 2, orderedIds.Count) - start); + + var adjacentQuery = context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => adjacentIds.Contains(e.Id)); + + return ApplyOrder(adjacentQuery, filter, context); + } + private IQueryable ApplyQueryPaging(IQueryable dbQuery, InternalItemsQuery filter) { if (filter.Limit.HasValue || filter.StartIndex.HasValue) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index 1ff8d8f863..b02d91b458 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -49,6 +49,7 @@ public sealed partial class BaseItemRepository dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); if (filter.EnableTotalRecordCount) { @@ -75,6 +76,7 @@ public sealed partial class BaseItemRepository dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random); diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 47f8a40b9c..1b73087296 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1243,33 +1243,6 @@ public sealed partial class BaseItemRepository } } - if (filter.AdjacentTo.HasValue && !filter.AdjacentTo.Value.IsEmpty()) - { - 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) - { - var targetSortName = targetItem.SortName ?? string.Empty; - - // Fetch both prev and next adjacent items in a single query using Concat (UNION ALL). - var adjacentIds = context.BaseItems - .Where(e => string.Compare(e.SortName, targetSortName) < 0) - .OrderByDescending(e => e.SortName) - .Select(e => e.Id) - .Take(1) - .Concat( - context.BaseItems - .Where(e => string.Compare(e.SortName, targetSortName) > 0) - .OrderBy(e => e.SortName) - .Select(e => e.Id) - .Take(1)) - .ToList(); - - adjacentIds.Add(adjacentToId); - baseQuery = baseQuery.Where(e => adjacentIds.Contains(e.Id)); - } - } - return baseQuery; } } diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index b1f7f29bad..ed11f21f52 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -1085,15 +1085,7 @@ namespace MediaBrowser.Controller.Entities items = ApplyNameFilter(items, query); } - var filteredItems = items as IReadOnlyList ?? items.ToList(); - var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager); - - if (query.EnableTotalRecordCount) - { - result.TotalRecordCount = filteredItems.Count; - } - - return result; + return UserViewBuilder.SortAndPage(items, null, query, LibraryManager); } private static IEnumerable ApplyNameFilter(IEnumerable items, InternalItemsQuery query) diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index 9ba103cc8b..fe7866fe65 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -490,6 +490,13 @@ namespace MediaBrowser.Controller.Entities } var itemsArray = totalRecordLimit.HasValue ? items.Take(totalRecordLimit.Value).ToArray() : items.ToArray(); + + // Adjacency is defined by the order the query asked for, so it has to run after sorting but before paging. + if (!query.AdjacentTo.IsNullOrEmpty()) + { + itemsArray = FilterForAdjacency(itemsArray, query.AdjacentTo.Value).ToArray(); + } + var totalCount = itemsArray.Length; if (query.Limit.HasValue && query.Limit.Value > 0) @@ -886,26 +893,32 @@ namespace MediaBrowser.Controller.Entities return _userViewManager.GetUserSubView(parent.Id, type, localizationKey, sortName); } - public static IEnumerable FilterForAdjacency(List list, Guid adjacentTo) + /// + /// Trims an ordered list down to the requested item and its immediate neighbours. + /// + /// The items in the order the query returned them. + /// The id of the item to return the neighbours of. + /// The previous item, the requested item and the next item, in order. + public static IEnumerable FilterForAdjacency(IReadOnlyList list, Guid adjacentTo) { - var adjacentToItem = list.FirstOrDefault(i => i.Id.Equals(adjacentTo)); - - var index = list.IndexOf(adjacentToItem); - - var previousId = Guid.Empty; - var nextId = Guid.Empty; - - if (index > 0) + var index = -1; + for (var i = 0; i < list.Count; i++) { - previousId = list[index - 1].Id; + if (list[i].Id.Equals(adjacentTo)) + { + index = i; + break; + } } - if (index < list.Count - 1) + // The item isn't part of this result set, so it has no neighbours in it either. + if (index < 0) { - nextId = list[index + 1].Id; + return []; } - return list.Where(i => i.Id.Equals(previousId) || i.Id.Equals(nextId) || i.Id.Equals(adjacentTo)); + var start = Math.Max(index - 1, 0); + return list.Skip(start).Take(Math.Min(index + 2, list.Count) - start); } } } -- cgit v1.2.3