diff options
Diffstat (limited to 'MediaBrowser.Controller')
| -rw-r--r-- | MediaBrowser.Controller/Entities/BaseItem.cs | 94 | ||||
| -rw-r--r-- | MediaBrowser.Controller/Entities/Folder.cs | 38 | ||||
| -rw-r--r-- | MediaBrowser.Controller/Entities/Movies/Movie.cs | 2 | ||||
| -rw-r--r-- | MediaBrowser.Controller/Entities/MusicVideo.cs | 2 | ||||
| -rw-r--r-- | MediaBrowser.Controller/Entities/TV/Series.cs | 52 | ||||
| -rw-r--r-- | MediaBrowser.Controller/Entities/Trailer.cs | 2 | ||||
| -rw-r--r-- | MediaBrowser.Controller/Entities/UserViewBuilder.cs | 48 | ||||
| -rw-r--r-- | MediaBrowser.Controller/Entities/Video.cs | 74 | ||||
| -rw-r--r-- | MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs | 26 |
9 files changed, 272 insertions, 66 deletions
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 21a726aaec..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,33 +1543,59 @@ namespace MediaBrowser.Controller.Entities private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { + // 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.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<Guid, string>(); + 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 currentExtraIds = LibraryManager.GetItemList(new InternalItemsQuery() - { - OwnerIds = [item.Id] - }).Select(e => e.Id).ToArray(); + 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. + foreach (var extra in currentExtras) + { + if (extra.ExtraType is not null && InheritDatesFromOwner(item, extra)) + { + await extra.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + } + 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; } i.OwnerId = ownerId; i.ParentId = Guid.Empty; + return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken); }); @@ -2652,6 +2678,32 @@ namespace MediaBrowser.Controller.Entities } } + /// <summary> + /// Applies the owner's premiere date and production year to an owned item, returning whether anything changed. + /// </summary> + /// <param name="owner">The owner.</param> + /// <param name="ownedItem">The owned item.</param> + /// <returns><c>true</c> if the owned item was changed, else <c>false</c>.</returns> + internal static bool InheritDatesFromOwner(BaseItem owner, BaseItem ownedItem) + { + // Extras have no release date of their own, so the owner's is authoritative. + var changed = false; + + if (owner.ProductionYear is not null && ownedItem.ProductionYear != owner.ProductionYear) + { + ownedItem.ProductionYear = owner.ProductionYear; + changed = true; + } + + if (owner.PremiereDate is not null && ownedItem.PremiereDate != owner.PremiereDate) + { + ownedItem.PremiereDate = owner.PremiereDate; + changed = true; + } + + return changed; + } + protected async Task RefreshMetadataForOwnedItem(BaseItem ownedItem, bool copyTitleMetadata, MetadataRefreshOptions options, CancellationToken cancellationToken) { var newOptions = new MetadataRefreshOptions(options) @@ -2711,6 +2763,11 @@ namespace MediaBrowser.Controller.Entities ownedItem.CustomRating = item.CustomRating; newOptions.ForceSave = true; } + + if (InheritDatesFromOwner(item, ownedItem)) + { + newOptions.ForceSave = true; + } } await ownedItem.RefreshMetadata(newOptions, cancellationToken).ConfigureAwait(false); @@ -2877,6 +2934,25 @@ namespace MediaBrowser.Controller.Entities } /// <summary> + /// Gets the ids of this item and the versions of it whose extras it maintains. + /// </summary> + /// <returns>An array containing the version ids.</returns> + protected virtual Guid[] GetOwnedVersionIds() + { + return [Id]; + } + + /// <summary> + /// Gets the id of the version an extra belongs to. + /// </summary> + /// <param name="extra">The extra.</param> + /// <returns>The id of the owning version.</returns> + protected virtual Guid GetOwnerIdForExtra(BaseItem extra) + { + return Id; + } + + /// <summary> /// Get all extras associated with this item, sorted by <see cref="SortName"/>. /// </summary> /// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param> diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index b1f7f29bad..d8203ea6f2 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -43,11 +43,7 @@ namespace MediaBrowser.Controller.Entities public class Folder : BaseItem { private IEnumerable<BaseItem> _children; - - public Folder() - { - LinkedChildren = Array.Empty<LinkedChild>(); - } + private LinkedChild[] _linkedChildren = []; public static IUserViewManager UserViewManager { get; set; } @@ -63,7 +59,27 @@ namespace MediaBrowser.Controller.Entities /// Gets or sets the linked children. /// </summary> [JsonIgnore] - public LinkedChild[] LinkedChildren { get; set; } + public LinkedChild[] LinkedChildren + { + get => _linkedChildren; + set + { + _linkedChildren = value; + + // Assigning the collection means the caller knows the complete set of links. + LinkedChildrenLoaded = true; + } + } + + /// <summary> + /// Gets a value indicating whether <see cref="LinkedChildren"/> holds the stored set of links. + /// </summary> + /// <remarks> + /// An unloaded instance carries an empty array that means "unknown", not "no children" — + /// persisting it would delete every link the item has. + /// </remarks> + [JsonIgnore] + public bool LinkedChildrenLoaded { get; private set; } [JsonIgnore] public DateTime? DateLastMediaAdded { get; set; } @@ -1085,15 +1101,7 @@ namespace MediaBrowser.Controller.Entities items = ApplyNameFilter(items, query); } - var filteredItems = items as IReadOnlyList<BaseItem> ?? 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<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query) diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index e8817a29cf..8c3ce2ff58 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -90,7 +90,7 @@ namespace MediaBrowser.Controller.Entities.Movies { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); - if (!ProductionYear.HasValue) + if (ProductionYear is null) { var info = LibraryManager.ParseName(Name); diff --git a/MediaBrowser.Controller/Entities/MusicVideo.cs b/MediaBrowser.Controller/Entities/MusicVideo.cs index 237ad5198c..effbf98820 100644 --- a/MediaBrowser.Controller/Entities/MusicVideo.cs +++ b/MediaBrowser.Controller/Entities/MusicVideo.cs @@ -40,7 +40,7 @@ namespace MediaBrowser.Controller.Entities { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); - if (!ProductionYear.HasValue) + if (ProductionYear is null) { var info = LibraryManager.ParseName(Name); diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 952187c6e1..3ce241aca8 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using System.Threading; @@ -82,16 +81,23 @@ namespace MediaBrowser.Controller.Entities.TV { var userdatakeys = GetUserDataKeys(); - if (userdatakeys.Count > 1) + // The first user data key is a stable cross-folder identity. + // When none exists, fall back to the (normalized) series name. + var groupingKey = userdatakeys.Count > 1 + ? userdatakeys[0] + : GetNameBasedGroupingKey(); + + if (!string.IsNullOrEmpty(groupingKey)) { - return AddLibrariesToPresentationUniqueKey(userdatakeys[0]); + return AppendPreferredLanguage(groupingKey); } } return base.CreatePresentationUniqueKey(); } - private string AddLibrariesToPresentationUniqueKey(string key) + // The owning libraries are deliberately NOT part of the key. + private string AppendPreferredLanguage(string key) { var lang = GetPreferredMetadataLanguage(); if (!string.IsNullOrEmpty(lang)) @@ -99,16 +105,15 @@ namespace MediaBrowser.Controller.Entities.TV key += "-" + lang; } - var folders = LibraryManager.GetCollectionFolders(this) - .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) - .ToArray(); - - if (folders.Length == 0) - { - return key; - } + return key; + } - return key + "-" + string.Join('-', folders); + private string GetNameBasedGroupingKey() + { + // Prefix with the type so a series can never collide with a same-named item of another kind. + return string.IsNullOrEmpty(Name) + ? null + : "series-" + Name.ToLowerInvariant(); } private static string GetUniqueSeriesKey(BaseItem series) @@ -188,6 +193,25 @@ namespace MediaBrowser.Controller.Entities.TV return list; } + /// <inheritdoc /> + protected override Guid[] GetExtraOwnerIds() + { + if (!LibraryManager.GetLibraryOptions(this).EnableAutomaticSeriesGrouping) + { + return base.GetExtraOwnerIds(); + } + + // Setting PresentationUniqueKey on the query disables presentation-key grouping, so this + // returns every folder-item of the merged series rather than the collapsed survivor. + var ids = LibraryManager.GetItemIds(new InternalItemsQuery + { + PresentationUniqueKey = GetPresentationUniqueKey(), + IncludeItemTypes = [BaseItemKind.Series] + }); + + return ids.Count == 0 ? base.GetExtraOwnerIds() : ids.ToArray(); + } + public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) { return GetSeasons(user, new DtoOptions(true)); @@ -507,7 +531,7 @@ namespace MediaBrowser.Controller.Entities.TV { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); - if (!ProductionYear.HasValue) + if (ProductionYear is null) { var info = LibraryManager.ParseName(Name); diff --git a/MediaBrowser.Controller/Entities/Trailer.cs b/MediaBrowser.Controller/Entities/Trailer.cs index 939709215c..a2465eedf0 100644 --- a/MediaBrowser.Controller/Entities/Trailer.cs +++ b/MediaBrowser.Controller/Entities/Trailer.cs @@ -49,7 +49,7 @@ namespace MediaBrowser.Controller.Entities { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); - if (!ProductionYear.HasValue) + if (ProductionYear is null) { var info = LibraryManager.ParseName(Name); diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index c57ed2faf8..f9ad2d86e6 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -461,11 +461,12 @@ namespace MediaBrowser.Controller.Entities var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user); var isPlayedValue = query.IsPlayed.Value; - return itemList.Where(i => + return itemList.Where(item => { - if (i.IsFolder && counts.TryGetValue(i.Id, out var c)) + if (item is Folder) { - return (c.Total > 0 && c.Played == c.Total) == isPlayedValue; + var itemCount = counts.GetValueOrDefault(item.Id); + return (itemCount.Played >= itemCount.Total) == isPlayedValue; } return true; @@ -490,6 +491,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) @@ -730,7 +738,7 @@ namespace MediaBrowser.Controller.Entities // Apply year filter if (query.Years.Length > 0) { - if (!(item.ProductionYear.HasValue && query.Years.Contains(item.ProductionYear.Value))) + if (item.ProductionYear is null || !query.Years.Contains(item.ProductionYear.Value)) { return false; } @@ -886,26 +894,32 @@ namespace MediaBrowser.Controller.Entities return _userViewManager.GetUserSubView(parent.Id, type, localizationKey, sortName); } - public static IEnumerable<BaseItem> FilterForAdjacency(List<BaseItem> list, Guid adjacentTo) + /// <summary> + /// Trims an ordered list down to the requested item and its immediate neighbours. + /// </summary> + /// <param name="list">The items in the order the query returned them.</param> + /// <param name="adjacentTo">The id of the item to return the neighbours of.</param> + /// <returns>The previous item, the requested item and the next item, in order.</returns> + public static IEnumerable<BaseItem> FilterForAdjacency(IReadOnlyList<BaseItem> 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); } } } 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(); } + /// <inheritdoc /> + 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)]; + } + + /// <inheritdoc /> + 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; + } + + /// <summary> + /// 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. + /// </summary> + /// <param name="versionPath">The path of the version.</param> + /// <param name="extraDirectory">The directory the extra lives in.</param> + /// <param name="extraFileName">The file name of the extra, without extension.</param> + /// <returns>The length of the match.</returns> + private static int MatchedVersionNameLength(string versionPath, ReadOnlySpan<char> extraDirectory, ReadOnlySpan<char> 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 diff --git a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs index 2e29cbdbba..f9a050d591 100644 --- a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs +++ b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Linq.Expressions; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; @@ -79,17 +80,26 @@ public interface IItemQueryHelpers Guid ancestorId); /// <summary> - /// Builds an <see cref="IQueryable{Guid}"/> of folder IDs whose descendants are all played - /// for the given user. Composable into outer queries to avoid an extra DB roundtrip. + /// Builds a query for the playable leaf items a user can access. /// </summary> /// <param name="context">The database context the resulting query is bound to.</param> - /// <param name="folderIds">A query yielding candidate folder IDs.</param> - /// <param name="user">The user for access filtering and played status.</param> - /// <returns>An <see cref="IQueryable{Guid}"/> of fully-played folder IDs.</returns> - IQueryable<Guid> GetFullyPlayedFolderIdsQuery( + /// <param name="user">The user to filter accessible items for.</param> + /// <param name="includeOwnedItems">Whether to include alternate versions and owned items.</param> + /// <returns>The access-filtered leaf item queryable.</returns> + IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery( JellyfinDbContext context, - IQueryable<Guid> folderIds, - User user); + User user, + bool includeOwnedItems = false); + + /// <summary> + /// Builds a filter matching items that have at least one of <paramref name="descendants"/> below them. + /// </summary> + /// <param name="context">The database context the resulting filter is bound to.</param> + /// <param name="descendants">A query yielding the descendants to look for.</param> + /// <returns>A filter expression matching items with a matching descendant.</returns> + Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter( + JellyfinDbContext context, + IQueryable<BaseItemEntity> descendants); /// <summary> /// Deserializes a <see cref="BaseItemEntity"/> into a <see cref="BaseItem"/>. |
