aboutsummaryrefslogtreecommitdiff
path: root/MediaBrowser.Controller/Entities
diff options
context:
space:
mode:
Diffstat (limited to 'MediaBrowser.Controller/Entities')
-rw-r--r--MediaBrowser.Controller/Entities/Audio/MusicArtist.cs5
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs183
-rw-r--r--MediaBrowser.Controller/Entities/CollectionFolder.cs24
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs582
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs158
-rw-r--r--MediaBrowser.Controller/Entities/InternalPeopleQuery.cs10
-rw-r--r--MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs31
-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.cs69
-rw-r--r--MediaBrowser.Controller/Entities/Movies/Movie.cs10
-rw-r--r--MediaBrowser.Controller/Entities/TV/Episode.cs4
-rw-r--r--MediaBrowser.Controller/Entities/TV/Season.cs11
-rw-r--r--MediaBrowser.Controller/Entities/TV/Series.cs6
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs286
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs202
17 files changed, 1025 insertions, 611 deletions
diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
index 58841e5b78..c25694aba5 100644
--- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
+++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
@@ -154,11 +154,6 @@ namespace MediaBrowser.Controller.Entities.Audio
return "Artist-" + (Name ?? string.Empty).RemoveDiacritics();
}
- protected override bool GetBlockUnratedValue(User user)
- {
- return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music);
- }
-
public override UnratedItem GetBlockUnratedType()
{
return UnratedItem.Music;
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index d9d2d0e3a8..822b21c062 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -22,7 +22,6 @@ using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
@@ -107,7 +106,6 @@ namespace MediaBrowser.Controller.Entities
ImageInfos = Array.Empty<ItemImageInfo>();
ProductionLocations = Array.Empty<string>();
RemoteTrailers = Array.Empty<MediaUrl>();
- ExtraIds = Array.Empty<Guid>();
UserData = [];
}
@@ -398,8 +396,6 @@ namespace MediaBrowser.Controller.Entities
public int Height { get; set; }
- public Guid[] ExtraIds { get; set; }
-
/// <summary>
/// Gets the primary image path.
/// </summary>
@@ -492,6 +488,8 @@ namespace MediaBrowser.Controller.Entities
public static IItemRepository ItemRepository { get; set; }
+ public static IItemCountService ItemCountService { get; set; }
+
public static IChapterManager ChapterManager { get; set; }
public static IFileSystem FileSystem { get; set; }
@@ -1172,11 +1170,18 @@ namespace MediaBrowser.Controller.Entities
info.Video3DFormat = video.Video3DFormat;
info.Timestamp = video.Timestamp;
- if (video.IsShortcut)
+ if (video.IsShortcut && !string.IsNullOrEmpty(video.ShortcutPath))
{
- info.IsRemote = true;
- info.Path = video.ShortcutPath;
- info.Protocol = MediaSourceManager.GetPathProtocol(info.Path);
+ var shortcutProtocol = MediaSourceManager.GetPathProtocol(video.ShortcutPath);
+
+ // Only allow remote shortcut paths — local file paths in .strm files
+ // could be used to read arbitrary files from the server.
+ if (shortcutProtocol != MediaProtocol.File)
+ {
+ info.IsRemote = true;
+ info.Path = video.ShortcutPath;
+ info.Protocol = shortcutProtocol;
+ }
}
if (string.IsNullOrEmpty(info.Container))
@@ -1334,14 +1339,15 @@ namespace MediaBrowser.Controller.Entities
return false;
}
- if (GetParents().Any(i => !i.IsVisible(user, true)))
+ var parents = GetParents().ToList();
+ if (parents.Any(i => !i.IsVisible(user, true)))
{
return false;
}
if (checkFolders)
{
- var topParent = GetParents().LastOrDefault() ?? this;
+ var topParent = parents.Count > 0 ? parents[^1] : this;
if (string.IsNullOrEmpty(topParent.Path))
{
@@ -1352,8 +1358,27 @@ namespace MediaBrowser.Controller.Entities
if (itemCollectionFolders.Count > 0)
{
- var userCollectionFolders = LibraryManager.GetUserRootFolder().GetChildren(user, true).Select(i => i.Id).ToList();
- if (!itemCollectionFolders.Any(userCollectionFolders.Contains))
+ var blockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders);
+ IEnumerable<Guid> userCollectionFolderIds;
+ if (blockedMediaFolders.Length > 0)
+ {
+ // User has blocked folders - get all library folders and exclude blocked ones
+ userCollectionFolderIds = LibraryManager.GetUserRootFolder().Children
+ .Select(i => i.Id)
+ .Where(id => !blockedMediaFolders.Contains(id));
+ }
+ else if (user.HasPermission(PermissionKind.EnableAllFolders))
+ {
+ // User can access all folders - no need to filter
+ return true;
+ }
+ else
+ {
+ // User has specific enabled folders
+ userCollectionFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders);
+ }
+
+ if (!itemCollectionFolders.Any(userCollectionFolderIds.Contains))
{
return false;
}
@@ -1395,7 +1420,13 @@ namespace MediaBrowser.Controller.Entities
{
var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray();
var newExtraIds = Array.ConvertAll(extras, x => x.Id);
- var extrasChanged = !item.ExtraIds.SequenceEqual(newExtraIds);
+
+ var currentExtraIds = LibraryManager.GetItemList(new InternalItemsQuery()
+ {
+ OwnerIds = [item.Id]
+ }).Select(e => e.Id).ToArray();
+
+ var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x));
if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh)
{
@@ -1409,16 +1440,15 @@ namespace MediaBrowser.Controller.Entities
var subOptions = new MetadataRefreshOptions(options);
if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty())
{
- i.OwnerId = ownerId;
- i.ParentId = Guid.Empty;
subOptions.ForceSave = true;
}
+ i.OwnerId = ownerId;
+ i.ParentId = Guid.Empty;
return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken);
});
- // Cleanup removed extras
- var removedExtraIds = item.ExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray();
+ var removedExtraIds = currentExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray();
if (removedExtraIds.Length > 0)
{
var removedExtras = LibraryManager.GetItemList(new InternalItemsQuery()
@@ -1427,17 +1457,20 @@ namespace MediaBrowser.Controller.Entities
});
foreach (var removedExtra in removedExtras)
{
- LibraryManager.DeleteItem(removedExtra, new DeleteOptions()
+ // Only delete items that are actual extras (have ExtraType set)
+ // Items with OwnerId but no ExtraType might be alternate versions, not extras
+ if (removedExtra.ExtraType.HasValue)
{
- DeleteFileLocation = false
- });
+ LibraryManager.DeleteItem(removedExtra, new DeleteOptions()
+ {
+ DeleteFileLocation = false
+ });
+ }
}
}
await Task.WhenAll(tasks).ConfigureAwait(false);
- item.ExtraIds = newExtraIds;
-
return true;
}
@@ -1601,11 +1634,10 @@ namespace MediaBrowser.Controller.Entities
if (string.IsNullOrEmpty(rating))
{
- Logger.LogDebug("{0} has no parental rating set.", Name);
return !GetBlockUnratedValue(user);
}
- var ratingScore = LocalizationManager.GetRatingScore(rating);
+ var ratingScore = LocalizationManager.GetRatingScore(rating, GetPreferredMetadataCountryCode());
// Could not determine rating level
if (ratingScore is null)
@@ -1647,7 +1679,7 @@ namespace MediaBrowser.Controller.Entities
return null;
}
- return LocalizationManager.GetRatingScore(rating);
+ return LocalizationManager.GetRatingScore(rating, GetPreferredMetadataCountryCode());
}
public List<string> GetInheritedTags()
@@ -1668,10 +1700,28 @@ namespace MediaBrowser.Controller.Entities
return list.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
- private bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck)
+ protected bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck)
{
- var allTags = GetInheritedTags();
- if (user.GetPreference(PreferenceKind.BlockedTags).Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase)))
+ var blockedTags = user.GetPreference(PreferenceKind.BlockedTags);
+ var allowedTags = user.GetPreference(PreferenceKind.AllowedTags);
+
+ if (blockedTags.Length == 0 && allowedTags.Length == 0)
+ {
+ return true;
+ }
+
+ // Normalize tags using the same logic as database queries
+ var normalizedBlockedTags = blockedTags
+ .Where(t => !string.IsNullOrWhiteSpace(t))
+ .Select(t => t.GetCleanValue())
+ .ToHashSet(StringComparer.Ordinal);
+
+ var normalizedItemTags = GetInheritedTags()
+ .Select(t => t.GetCleanValue())
+ .ToHashSet(StringComparer.Ordinal);
+
+ // Check blocked tags - item is hidden if it has any blocked tag
+ if (normalizedBlockedTags.Overlaps(normalizedItemTags))
{
return false;
}
@@ -1682,10 +1732,18 @@ namespace MediaBrowser.Controller.Entities
return true;
}
- var allowedTagsPreference = user.GetPreference(PreferenceKind.AllowedTags);
- if (!skipAllowedTagsCheck && allowedTagsPreference.Length != 0 && !allowedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase)))
+ // Check allowed tags - item must have at least one allowed tag
+ if (!skipAllowedTagsCheck && allowedTags.Length > 0)
{
- return false;
+ var normalizedAllowedTags = allowedTags
+ .Where(t => !string.IsNullOrWhiteSpace(t))
+ .Select(t => t.GetCleanValue())
+ .ToHashSet(StringComparer.Ordinal);
+
+ if (!normalizedAllowedTags.Overlaps(normalizedItemTags))
+ {
+ return false;
+ }
}
return true;
@@ -1798,10 +1856,23 @@ namespace MediaBrowser.Controller.Entities
return item;
}
+#pragma warning disable CS0618 // Type or member is obsolete - fallback for legacy LinkedChild data
private BaseItem FindLinkedChild(LinkedChild info)
{
- var path = info.Path;
+ // First try to find by ItemId (new preferred method)
+ if (info.ItemId.HasValue && !info.ItemId.Value.Equals(Guid.Empty))
+ {
+ var item = LibraryManager.GetItemById(info.ItemId.Value);
+ if (item is not null)
+ {
+ return item;
+ }
+ Logger.LogWarning("Unable to find linked item by ItemId {0}", info.ItemId);
+ }
+
+ // Fall back to Path (legacy method)
+ var path = info.Path;
if (!string.IsNullOrEmpty(path))
{
path = FileSystem.MakeAbsolutePath(ContainingFolderPath, path);
@@ -1816,13 +1887,14 @@ namespace MediaBrowser.Controller.Entities
return itemByPath;
}
+ // Fall back to LibraryItemId (legacy method)
if (!string.IsNullOrEmpty(info.LibraryItemId))
{
var item = LibraryManager.GetItemById(info.LibraryItemId);
if (item is null)
{
- Logger.LogWarning("Unable to find linked item at path {0}", info.Path);
+ Logger.LogWarning("Unable to find linked item by LibraryItemId {0}", info.LibraryItemId);
}
return item;
@@ -1830,6 +1902,7 @@ namespace MediaBrowser.Controller.Entities
return null;
}
+#pragma warning restore CS0618
/// <summary>
/// Adds a studio to the item.
@@ -2053,6 +2126,9 @@ namespace MediaBrowser.Controller.Entities
public virtual async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
=> await LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken).ConfigureAwait(false);
+ public async Task ReattachUserDataAsync(CancellationToken cancellationToken) =>
+ await LibraryManager.ReattachUserDataAsync(this, cancellationToken).ConfigureAwait(false);
+
/// <summary>
/// Validates that images within the item are still on the filesystem.
/// </summary>
@@ -2126,17 +2202,6 @@ namespace MediaBrowser.Controller.Entities
};
}
- // Music albums usually don't have dedicated backdrops, so return one from the artist instead
- if (GetType() == typeof(MusicAlbum) && imageType == ImageType.Backdrop)
- {
- var artist = FindParent<MusicArtist>();
-
- if (artist is not null)
- {
- return artist.GetImages(imageType).ElementAtOrDefault(imageIndex);
- }
- }
-
return GetImages(imageType)
.ElementAtOrDefault(imageIndex);
}
@@ -2418,7 +2483,13 @@ namespace MediaBrowser.Controller.Entities
return path;
}
- public virtual void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields)
+ public virtual void FillUserDataDtoValues(
+ UserItemDataDto dto,
+ UserItemData userData,
+ BaseItemDto itemDto,
+ User user,
+ DtoOptions fields,
+ (int Played, int Total)? precomputedCounts = null)
{
if (RunTimeTicks.HasValue)
{
@@ -2618,7 +2689,7 @@ namespace MediaBrowser.Controller.Entities
.Select(i => i.OfficialRating)
.Where(i => !string.IsNullOrEmpty(i))
.Distinct(StringComparer.OrdinalIgnoreCase)
- .Select(rating => (rating, LocalizationManager.GetRatingScore(rating)))
+ .Select(rating => (rating, LocalizationManager.GetRatingScore(rating, GetPreferredMetadataCountryCode())))
.OrderBy(i => i.Item2 is null ? 1001 : i.Item2.Score)
.ThenBy(i => i.Item2 is null ? 1001 : i.Item2.SubScore)
.Select(i => i.rating);
@@ -2657,10 +2728,11 @@ namespace MediaBrowser.Controller.Entities
/// <returns>An enumerable containing the items.</returns>
public IEnumerable<BaseItem> GetExtras()
{
- return ExtraIds
- .Select(LibraryManager.GetItemById)
- .Where(i => i is not null)
- .OrderBy(i => i.SortName);
+ return LibraryManager.GetItemList(new InternalItemsQuery()
+ {
+ OwnerIds = [Id],
+ OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
+ });
}
/// <summary>
@@ -2670,11 +2742,12 @@ namespace MediaBrowser.Controller.Entities
/// <returns>An enumerable containing the extras.</returns>
public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes)
{
- return ExtraIds
- .Select(LibraryManager.GetItemById)
- .Where(i => i is not null)
- .Where(i => i.ExtraType.HasValue && extraTypes.Contains(i.ExtraType.Value))
- .OrderBy(i => i.SortName);
+ return LibraryManager.GetItemList(new InternalItemsQuery()
+ {
+ OwnerIds = [Id],
+ ExtraTypes = extraTypes.ToArray(),
+ OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
+ });
}
public virtual long GetRunTimeTicksForPlayState()
diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs
index ca79e62454..ffdc8421da 100644
--- a/MediaBrowser.Controller/Entities/CollectionFolder.cs
+++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs
@@ -45,6 +45,11 @@ namespace MediaBrowser.Controller.Entities
}
/// <summary>
+ /// Event raised when library options are updated for any collection folder.
+ /// </summary>
+ public static event EventHandler<LibraryOptionsUpdatedEventArgs> LibraryOptionsUpdated;
+
+ /// <summary>
/// Gets the display preferences id.
/// </summary>
/// <remarks>
@@ -74,14 +79,27 @@ namespace MediaBrowser.Controller.Entities
public CollectionType? CollectionType { get; set; }
/// <summary>
- /// Gets the item's children.
+ /// Gets or sets the item's children.
/// </summary>
/// <remarks>
/// Our children are actually just references to the ones in the physical root...
+ /// Setting to null propagates invalidation to physical folders since the getter
+ /// always delegates to <see cref="GetActualChildren"/> and never reads the backing field.
/// </remarks>
/// <value>The actual children.</value>
[JsonIgnore]
- public override IEnumerable<BaseItem> Children => GetActualChildren();
+ public override IEnumerable<BaseItem> Children
+ {
+ get => GetActualChildren();
+ set
+ {
+ // The getter delegates to physical folders, so invalidate their caches.
+ foreach (var folder in GetPhysicalFolders(true))
+ {
+ folder.Children = null;
+ }
+ }
+ }
[JsonIgnore]
public override bool SupportsPeople => false;
@@ -168,6 +186,8 @@ namespace MediaBrowser.Controller.Entities
}
XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path));
+
+ LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, options));
}
public static void OnCollectionFolderChange()
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index d2a3290c47..5fa1213db3 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -59,6 +59,10 @@ namespace MediaBrowser.Controller.Entities
/// <value><c>true</c> if this instance is root; otherwise, <c>false</c>.</value>
public bool IsRoot { get; set; }
+ /// <summary>
+ /// Gets or sets the linked children.
+ /// </summary>
+ [JsonIgnore]
public LinkedChild[] LinkedChildren { get; set; }
[JsonIgnore]
@@ -416,6 +420,17 @@ namespace MediaBrowser.Controller.Entities
// Create a list for our validated children
var newItems = new List<BaseItem>();
+ var actuallyRemoved = new List<BaseItem>();
+
+ // Build a reverse path→item lookup for detecting type changes
+ var currentChildrenByPath = new Dictionary<string, BaseItem>(StringComparer.OrdinalIgnoreCase);
+ foreach (var kvp in currentChildren)
+ {
+ if (!string.IsNullOrEmpty(kvp.Value.Path))
+ {
+ currentChildrenByPath.TryAdd(kvp.Value.Path, kvp.Value);
+ }
+ }
cancellationToken.ThrowIfCancellationRequested();
@@ -443,6 +458,24 @@ namespace MediaBrowser.Controller.Entities
continue;
}
+ // Check if an existing item occupies the same path with different type/ID
+ if (!string.IsNullOrEmpty(child.Path)
+ && currentChildrenByPath.TryGetValue(child.Path, out var staleItem)
+ && !staleItem.Id.Equals(child.Id))
+ {
+ Logger.LogInformation(
+ "Item type changed at {Path}: {OldType} -> {NewType}, removing stale entry",
+ child.Path,
+ staleItem.GetType().Name,
+ child.GetType().Name);
+
+ currentChildren.Remove(staleItem.Id);
+ currentChildrenByPath.Remove(child.Path);
+ staleItem.SetParent(null);
+ LibraryManager.DeleteItem(staleItem, new DeleteOptions { DeleteFileLocation = false }, this, false);
+ actuallyRemoved.Add(staleItem);
+ }
+
// Brand new item - needs to be added
child.SetParent(this);
newItems.Add(child);
@@ -453,6 +486,17 @@ namespace MediaBrowser.Controller.Entities
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
var shouldRemove = !IsRoot || allowRemoveRoot;
// If it's an AggregateFolder, don't remove
+ // Collect replaced primaries for deferred deletion (after CreateItems)
+ var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>();
+
+ // Build a set of paths that are alternate versions of valid children
+ // These items should not be deleted - they're managed by their primary video
+ var alternateVersionPaths = validChildren
+ .OfType<Video>()
+ .SelectMany(v => v.LocalAlternateVersions ?? [])
+ .Where(p => !string.IsNullOrEmpty(p))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
if (shouldRemove && itemsRemoved.Count > 0)
{
foreach (var item in itemsRemoved)
@@ -463,10 +507,45 @@ namespace MediaBrowser.Controller.Entities
continue;
}
+ // Skip items that are alternate versions of another video
+ if (item is Video video)
+ {
+ // Check if path is in LocalAlternateVersions of any valid child
+ if (!string.IsNullOrEmpty(item.Path) && alternateVersionPaths.Contains(item.Path))
+ {
+ Logger.LogDebug("Item path matches an alternate version, skipping deletion: {Path}", item.Path);
+ continue;
+ }
+ }
+
+ // Defer deletion if this primary video is being replaced by a new primary
+ // that takes over its alternates. Deleting now would trigger premature
+ // promotion inside DeleteItem and write stale paths to collection NFOs.
+ if (item is Video primaryVideo
+ && !primaryVideo.PrimaryVersionId.HasValue
+ && primaryVideo.OwnerId.IsEmpty()
+ && (primaryVideo.LocalAlternateVersions ?? []).Any(p => alternateVersionPaths.Contains(p)))
+ {
+ var newPrimary = newItems
+ .OfType<Video>()
+ .FirstOrDefault(v => (v.LocalAlternateVersions ?? [])
+ .Any(p => (primaryVideo.LocalAlternateVersions ?? [])
+ .Any(op => string.Equals(op, p, StringComparison.OrdinalIgnoreCase))));
+ if (newPrimary is not null)
+ {
+ Logger.LogDebug("Deferring deletion of replaced primary: {Path}", item.Path);
+ replacedPrimaries.Add((primaryVideo, newPrimary));
+ actuallyRemoved.Add(item);
+ item.SetParent(null);
+ continue;
+ }
+ }
+
if (item.IsFileProtocol)
{
Logger.LogDebug("Removed item: {Path}", item.Path);
+ actuallyRemoved.Add(item);
item.SetParent(null);
LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false);
}
@@ -477,6 +556,120 @@ namespace MediaBrowser.Controller.Entities
{
LibraryManager.CreateItems(newItems, this, cancellationToken);
}
+
+ // Process deferred replaced-primary deletions now that new primaries exist in DB/cache.
+ // This avoids the premature promotion that would occur if DeleteItem ran before CreateItems.
+ foreach (var (oldPrimary, newPrimary) in replacedPrimaries)
+ {
+ Logger.LogInformation(
+ "Processing deferred deletion of replaced primary {OldName} ({OldId}), new primary {NewName} ({NewId})",
+ oldPrimary.Name,
+ oldPrimary.Id,
+ newPrimary.Name,
+ newPrimary.Id);
+
+ // Reroute collection/playlist references from old primary to new primary
+ await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false);
+
+ // Transfer alternates from old primary to new primary
+ var localAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary).ToHashSet();
+ var allAlternateIds = localAlternateIds
+ .Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id))
+ .Distinct()
+ .ToList();
+
+ foreach (var altId in allAlternateIds)
+ {
+ if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id))
+ {
+ altVideo.SetPrimaryVersionId(newPrimary.Id);
+ altVideo.OwnerId = localAlternateIds.Contains(altVideo.Id) ? newPrimary.Id : Guid.Empty;
+ await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ // Clear alternate arrays so DeleteItem won't trigger promotion
+ oldPrimary.LocalAlternateVersions = [];
+ oldPrimary.LinkedAlternateVersions = [];
+
+ // Safe to delete now — no promotion will happen
+ LibraryManager.DeleteItem(oldPrimary, new DeleteOptions { DeleteFileLocation = false }, this, false);
+ }
+
+ // Demote old primaries that are now alternate versions of newly created primaries.
+ // This handles the case where a new file is added that becomes the new primary
+ // (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion).
+ // Items in replacedPrimaries are excluded (already in actuallyRemoved).
+ var oldPrimariesToDemote = new List<(Video OldPrimary, Video NewPrimary)>();
+ foreach (var item in itemsRemoved.Except(actuallyRemoved))
+ {
+ if (item is Video video
+ && video.OwnerId.IsEmpty()
+ && !string.IsNullOrEmpty(item.Path)
+ && alternateVersionPaths.Contains(item.Path))
+ {
+ var newPrimary = newItems
+ .OfType<Video>()
+ .FirstOrDefault(v => (v.LocalAlternateVersions ?? [])
+ .Any(p => string.Equals(p, item.Path, StringComparison.OrdinalIgnoreCase)));
+ if (newPrimary is not null)
+ {
+ oldPrimariesToDemote.Add((video, newPrimary));
+ }
+ }
+ }
+
+ foreach (var (oldPrimary, newPrimary) in oldPrimariesToDemote)
+ {
+ Logger.LogInformation(
+ "Demoting old primary {OldName} ({OldId}) to alternate of new primary {NewName} ({NewId})",
+ oldPrimary.Name,
+ oldPrimary.Id,
+ newPrimary.Name,
+ newPrimary.Id);
+
+ // First: update old primary's alternate items to point to new primary.
+ // Order matters — update alternates FIRST so they don't get orphan-deleted
+ // when old primary's arrays are cleared.
+ var oldAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary)
+ .Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id))
+ .Distinct()
+ .ToList();
+
+ foreach (var altId in oldAlternateIds)
+ {
+ if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id))
+ {
+ altVideo.SetPrimaryVersionId(newPrimary.Id);
+ altVideo.OwnerId = newPrimary.Id;
+ await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ // Then: demote old primary — clear its arrays and set it as alternate of new primary
+ oldPrimary.LocalAlternateVersions = [];
+ oldPrimary.LinkedAlternateVersions = [];
+ oldPrimary.SetPrimaryVersionId(newPrimary.Id);
+ oldPrimary.OwnerId = newPrimary.Id;
+ await oldPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
+
+ // Re-route playlist/collection references from old primary to new primary
+ await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false);
+ }
+
+ // After removing items, reattach any detached user data to remaining children
+ // that share the same user data keys (eg. same episode replaced with a new file).
+ if (actuallyRemoved.Count > 0)
+ {
+ var removedKeys = actuallyRemoved.SelectMany(i => i.GetUserDataKeys()).ToHashSet();
+ foreach (var child in validChildren)
+ {
+ if (child.GetUserDataKeys().Any(removedKeys.Contains))
+ {
+ await child.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
}
else
{
@@ -700,36 +893,10 @@ namespace MediaBrowser.Controller.Entities
public QueryResult<BaseItem> QueryRecursive(InternalItemsQuery query)
{
- var user = query.User;
-
- if (!query.ForceDirect && RequiresPostFiltering(query))
+ if (!query.ForceDirect && CollapseBoxSetItems(query, this, query.User, ConfigurationManager))
{
- IEnumerable<BaseItem> items;
- Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager);
-
- var totalCount = 0;
- if (query.User is null)
- {
- items = GetRecursiveChildren(filter);
- totalCount = items.Count();
- }
- else
- {
- // Save pagination params before clearing them to prevent pagination from happening
- // before sorting. PostFilterAndSort will apply pagination after sorting.
- var limit = query.Limit;
- var startIndex = query.StartIndex;
- query.Limit = null;
- query.StartIndex = null;
-
- items = GetRecursiveChildren(user, query, out totalCount);
-
- // Restore pagination params so PostFilterAndSort can apply them after sorting
- query.Limit = limit;
- query.StartIndex = startIndex;
- }
-
- return PostFilterAndSort(items, query);
+ query.CollapseBoxSetItems = true;
+ SetCollapseBoxSetItemTypes(query);
}
if (this is not UserRootFolder
@@ -739,15 +906,15 @@ namespace MediaBrowser.Controller.Entities
query.Parent = this;
}
- if (RequiresPostFiltering2(query))
+ if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet)
{
- return QueryWithPostFiltering2(query);
+ return QueryWithPostFiltering(query);
}
return LibraryManager.GetItemsResult(query);
}
- protected QueryResult<BaseItem> QueryWithPostFiltering2(InternalItemsQuery query)
+ protected QueryResult<BaseItem> QueryWithPostFiltering(InternalItemsQuery query)
{
var startIndex = query.StartIndex;
var limit = query.Limit;
@@ -793,120 +960,6 @@ namespace MediaBrowser.Controller.Entities
returnItems.ToArray());
}
- private bool RequiresPostFiltering2(InternalItemsQuery query)
- {
- if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet)
- {
- Logger.LogDebug("Query requires post-filtering due to BoxSet query");
- return true;
- }
-
- return false;
- }
-
- private bool RequiresPostFiltering(InternalItemsQuery query)
- {
- if (LinkedChildren.Length > 0)
- {
- if (this is not ICollectionFolder)
- {
- Logger.LogDebug("{Type}: Query requires post-filtering due to LinkedChildren.", GetType().Name);
- return true;
- }
- }
-
- // Filter by Video3DFormat
- if (query.Is3D.HasValue)
- {
- Logger.LogDebug("Query requires post-filtering due to Is3D");
- return true;
- }
-
- if (query.HasOfficialRating.HasValue)
- {
- Logger.LogDebug("Query requires post-filtering due to HasOfficialRating");
- return true;
- }
-
- if (query.IsPlaceHolder.HasValue)
- {
- Logger.LogDebug("Query requires post-filtering due to IsPlaceHolder");
- return true;
- }
-
- if (query.HasSpecialFeature.HasValue)
- {
- Logger.LogDebug("Query requires post-filtering due to HasSpecialFeature");
- return true;
- }
-
- if (query.HasSubtitles.HasValue)
- {
- Logger.LogDebug("Query requires post-filtering due to HasSubtitles");
- return true;
- }
-
- if (query.HasTrailer.HasValue)
- {
- Logger.LogDebug("Query requires post-filtering due to HasTrailer");
- return true;
- }
-
- if (query.HasThemeSong.HasValue)
- {
- Logger.LogDebug("Query requires post-filtering due to HasThemeSong");
- return true;
- }
-
- if (query.HasThemeVideo.HasValue)
- {
- Logger.LogDebug("Query requires post-filtering due to HasThemeVideo");
- return true;
- }
-
- // Filter by VideoType
- if (query.VideoTypes.Length > 0)
- {
- Logger.LogDebug("Query requires post-filtering due to VideoTypes");
- return true;
- }
-
- if (CollapseBoxSetItems(query, this, query.User, ConfigurationManager))
- {
- Logger.LogDebug("Query requires post-filtering due to CollapseBoxSetItems");
- return true;
- }
-
- if (!query.AdjacentTo.IsNullOrEmpty())
- {
- Logger.LogDebug("Query requires post-filtering due to AdjacentTo");
- return true;
- }
-
- if (query.SeriesStatuses.Length > 0)
- {
- Logger.LogDebug("Query requires post-filtering due to SeriesStatuses");
- return true;
- }
-
- if (query.AiredDuringSeason.HasValue)
- {
- Logger.LogDebug("Query requires post-filtering due to AiredDuringSeason");
- return true;
- }
-
- if (query.IsPlayed.HasValue)
- {
- if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(BaseItemKind.Series))
- {
- Logger.LogDebug("Query requires post-filtering due to IsPlayed");
- return true;
- }
- }
-
- return false;
- }
-
private static BaseItem[] SortItemsByRequest(InternalItemsQuery query, IReadOnlyList<BaseItem> items)
{
return items.OrderBy(i => Array.IndexOf(query.ItemIds, i.Id)).ToArray();
@@ -974,14 +1027,12 @@ namespace MediaBrowser.Controller.Entities
var user = query.User;
- Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager);
-
IEnumerable<BaseItem> items;
int totalItemCount = 0;
if (query.User is null)
{
- items = Children.Where(filter);
+ items = UserViewBuilder.Filter(Children, user, query, UserDataManager, LibraryManager);
totalItemCount = items.Count();
}
else
@@ -996,7 +1047,12 @@ namespace MediaBrowser.Controller.Entities
NameLessThan = query.NameLessThan
};
- items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter);
+ items = UserViewBuilder.Filter(
+ GetChildren(user, true, out totalItemCount, childQuery),
+ user,
+ query,
+ UserDataManager,
+ LibraryManager);
}
return PostFilterAndSort(items, query);
@@ -1010,40 +1066,42 @@ namespace MediaBrowser.Controller.Entities
if (user is not null)
{
items = CollapseBoxSetItemsIfNeeded(items, query, this, user, ConfigurationManager, CollectionManager);
- }
-#pragma warning disable CA1309
- if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater))
- {
- items = items.Where(i => string.Compare(query.NameStartsWithOrGreater, i.SortName, StringComparison.InvariantCultureIgnoreCase) < 1);
+ // After collapse, BoxSets may have replaced items whose names matched the filter
+ // but the BoxSet's own name may not match. Re-apply name filtering so BoxSets
+ // appear under the correct letter (e.g. "Jump Street" under J, not under #).
+ items = ApplyNameFilter(items, query);
}
- if (!string.IsNullOrEmpty(query.NameStartsWith))
+ var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
+ var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager);
+
+ if (query.EnableTotalRecordCount)
{
- items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase));
+ result.TotalRecordCount = filteredItems.Count;
}
- if (!string.IsNullOrEmpty(query.NameLessThan))
+ return result;
+ }
+
+ private static IEnumerable<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query)
+ {
+ if (!string.IsNullOrWhiteSpace(query.NameStartsWith))
{
- items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.InvariantCultureIgnoreCase) == 1);
+ items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.OrdinalIgnoreCase));
}
-#pragma warning restore CA1309
- // This must be the last filter
- if (!query.AdjacentTo.IsNullOrEmpty())
+ if (!string.IsNullOrWhiteSpace(query.NameStartsWithOrGreater))
{
- items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
+ items = items.Where(i => string.Compare(i.SortName, query.NameStartsWithOrGreater, StringComparison.OrdinalIgnoreCase) >= 0);
}
- var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
- var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager);
-
- if (query.EnableTotalRecordCount)
+ if (!string.IsNullOrWhiteSpace(query.NameLessThan))
{
- result.TotalRecordCount = filteredItems.Count;
+ items = items.Where(i => string.Compare(i.SortName, query.NameLessThan, StringComparison.OrdinalIgnoreCase) < 0);
}
- return result;
+ return items;
}
private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded(
@@ -1151,6 +1209,33 @@ namespace MediaBrowser.Controller.Entities
return (queryHasMovies || queryHasSeries) && AllowBoxSetCollapsing(query);
}
+ private void SetCollapseBoxSetItemTypes(InternalItemsQuery query)
+ {
+ var config = ConfigurationManager.Configuration;
+ bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
+ bool collapseSeries = config.EnableGroupingShowsIntoCollections;
+
+ if (collapseMovies && collapseSeries)
+ {
+ // Empty means collapse all types
+ query.CollapseBoxSetItemTypes = [];
+ return;
+ }
+
+ var types = new List<BaseItemKind>();
+ if (collapseMovies)
+ {
+ types.Add(BaseItemKind.Movie);
+ }
+
+ if (collapseSeries)
+ {
+ types.Add(BaseItemKind.Series);
+ }
+
+ query.CollapseBoxSetItemTypes = types.ToArray();
+ }
+
private static bool AllowBoxSetCollapsing(InternalItemsQuery request)
{
if (request.IsFavorite.HasValue)
@@ -1402,8 +1487,7 @@ namespace MediaBrowser.Controller.Entities
.Where(e => e.IsVisible(user))
.ToArray();
- var realChildren = visibleChildren
- .Where(e => query is null || UserViewBuilder.FilterItem(e, query))
+ var realChildren = UserViewBuilder.Filter(visibleChildren, query.User, query, UserDataManager, LibraryManager)
.ToArray();
var childCount = realChildren.Length;
@@ -1509,17 +1593,11 @@ namespace MediaBrowser.Controller.Entities
/// <returns>IEnumerable{BaseItem}.</returns>
public List<BaseItem> GetLinkedChildren()
{
- var linkedChildren = LinkedChildren;
- var list = new List<BaseItem>(linkedChildren.Length);
-
- foreach (var i in linkedChildren)
+ var resolved = ResolveLinkedChildren(LinkedChildren);
+ var list = new List<BaseItem>(resolved.Count);
+ foreach (var (_, item) in resolved)
{
- var child = GetLinkedChild(i);
-
- if (child is not null)
- {
- list.Add(child);
- }
+ list.Add(item);
}
return list;
@@ -1620,12 +1698,74 @@ namespace MediaBrowser.Controller.Entities
/// <returns>IEnumerable{BaseItem}.</returns>
public IReadOnlyList<Tuple<LinkedChild, BaseItem>> GetLinkedChildrenInfos()
{
- return LinkedChildren
- .Select(i => new Tuple<LinkedChild, BaseItem>(i, GetLinkedChild(i)))
- .Where(i => i.Item2 is not null)
+ return ResolveLinkedChildren(LinkedChildren)
+ .Select(t => new Tuple<LinkedChild, BaseItem>(t.Info, t.Item))
.ToArray();
}
+ /// <summary>
+ /// Resolves a list of <see cref="LinkedChild"/> entries to their <see cref="BaseItem"/> targets,
+ /// batching the database lookup across all entries with a known ItemId.
+ /// Entries without a usable ItemId fall back to the per-entry <see cref="BaseItem.GetLinkedChild"/>
+ /// path (legacy path-based resolution).
+ /// </summary>
+ /// <param name="linkedChildren">Linked children to resolve.</param>
+ /// <returns>Each input entry paired with its resolved item; entries that fail to resolve are dropped.</returns>
+ private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList<LinkedChild> linkedChildren)
+ {
+ var resolved = new List<(LinkedChild Info, BaseItem Item)>(linkedChildren.Count);
+ if (linkedChildren.Count == 0)
+ {
+ return resolved;
+ }
+
+ var idsToBatch = new HashSet<Guid>();
+ foreach (var info in linkedChildren)
+ {
+ if (info.ItemId.HasValue && !info.ItemId.Value.IsEmpty())
+ {
+ idsToBatch.Add(info.ItemId.Value);
+ }
+ }
+
+ Dictionary<Guid, BaseItem> byId = null;
+ if (idsToBatch.Count > 0)
+ {
+ var batched = LibraryManager.GetItemList(new InternalItemsQuery
+ {
+ ItemIds = [.. idsToBatch]
+ });
+ byId = new Dictionary<Guid, BaseItem>(batched.Count);
+ foreach (var item in batched)
+ {
+ byId[item.Id] = item;
+ }
+ }
+
+ foreach (var info in linkedChildren)
+ {
+ BaseItem item = null;
+ if (byId is not null && info.ItemId.HasValue && byId.TryGetValue(info.ItemId.Value, out var batchedItem))
+ {
+ item = batchedItem;
+ }
+ else
+ {
+ // ItemId is missing/empty or the batched query couldn't return the item
+ // (e.g. it has been removed). Fall back to per-entry resolution, which also
+ // handles legacy path-based linked children.
+ item = GetLinkedChild(info);
+ }
+
+ if (item is not null)
+ {
+ resolved.Add((info, item));
+ }
+ }
+
+ return resolved;
+ }
+
protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
{
var changesFound = false;
@@ -1664,11 +1804,13 @@ namespace MediaBrowser.Controller.Entities
if (!string.IsNullOrEmpty(resolvedPath))
{
+#pragma warning disable CS0618 // Type or member is obsolete - shortcuts require Path for lazy ItemId resolution
return new LinkedChild
{
Path = resolvedPath,
Type = LinkedChildType.Shortcut
};
+#pragma warning restore CS0618
}
Logger.LogError("Error resolving shortcut {0}", i.FullName);
@@ -1696,12 +1838,6 @@ namespace MediaBrowser.Controller.Entities
}
}
- foreach (var child in LinkedChildren)
- {
- // Reset the cached value
- child.ItemId = null;
- }
-
return false;
}
@@ -1779,45 +1915,63 @@ namespace MediaBrowser.Controller.Entities
return !IsPlayed(user, userItemData);
}
- public override void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields)
+ public override void FillUserDataDtoValues(
+ UserItemDataDto dto,
+ UserItemData userData,
+ BaseItemDto itemDto,
+ User user,
+ DtoOptions fields,
+ (int Played, int Total)? precomputedCounts = null)
{
if (!SupportsUserDataFromChildren)
{
return;
}
- if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount))
+ if (SupportsPlayedStatus || (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)))
{
- itemDto.RecursiveItemCount = GetRecursiveChildCount(user);
- }
+ int playedCount;
+ int totalCount;
- if (SupportsPlayedStatus)
- {
- var unplayedQueryResult = GetItems(new InternalItemsQuery(user)
+ if (precomputedCounts.HasValue)
+ {
+ // Use batch-fetched counts (avoids N+1 queries)
+ (playedCount, totalCount) = precomputedCounts.Value;
+ }
+ else
{
- Recursive = true,
- IsFolder = false,
- IsVirtualItem = false,
- EnableTotalRecordCount = true,
- Limit = 0,
- IsPlayed = false,
- DtoOptions = new DtoOptions(false)
+ // Fall back to per-item query when no batch data is available
+ var query = new InternalItemsQuery(user);
+
+ if (LinkedChildren.Length > 0)
{
- EnableImages = false
+ (playedCount, totalCount) = ItemCountService.GetPlayedAndTotalCountFromLinkedChildren(query, Id);
}
- }).TotalRecordCount;
-
- dto.UnplayedItemCount = unplayedQueryResult;
+ else
+ {
+ (playedCount, totalCount) = ItemCountService.GetPlayedAndTotalCount(query, Id);
+ }
+ }
- if (itemDto?.RecursiveItemCount > 0)
+ if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount))
{
- var unplayedPercentage = ((double)unplayedQueryResult / itemDto.RecursiveItemCount.Value) * 100;
- dto.PlayedPercentage = 100 - unplayedPercentage;
- dto.Played = dto.PlayedPercentage.Value >= 100;
+ itemDto.RecursiveItemCount = totalCount;
}
- else
+
+ if (SupportsPlayedStatus)
{
- dto.Played = (dto.UnplayedItemCount ?? 0) == 0;
+ var unplayedCount = totalCount - playedCount;
+ dto.UnplayedItemCount = unplayedCount;
+
+ if (totalCount > 0)
+ {
+ dto.PlayedPercentage = playedCount / (double)totalCount * 100;
+ dto.Played = playedCount >= totalCount;
+ }
+ else
+ {
+ dto.Played = true;
+ }
}
}
}
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index 076a592922..fa82ea8663 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -10,6 +10,7 @@ using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
namespace MediaBrowser.Controller.Entities
{
@@ -17,42 +18,45 @@ namespace MediaBrowser.Controller.Entities
{
public InternalItemsQuery()
{
- AlbumArtistIds = Array.Empty<Guid>();
- AlbumIds = Array.Empty<Guid>();
- AncestorIds = Array.Empty<Guid>();
- ArtistIds = Array.Empty<Guid>();
- BlockUnratedItems = Array.Empty<UnratedItem>();
- BoxSetLibraryFolders = Array.Empty<Guid>();
- ChannelIds = Array.Empty<Guid>();
- ContributingArtistIds = Array.Empty<Guid>();
+ AlbumArtistIds = [];
+ AlbumIds = [];
+ AncestorIds = [];
+ ArtistIds = [];
+ BlockUnratedItems = [];
+ BoxSetLibraryFolders = [];
+ ChannelIds = [];
+ ContributingArtistIds = [];
DtoOptions = new DtoOptions();
EnableTotalRecordCount = true;
- ExcludeArtistIds = Array.Empty<Guid>();
- ExcludeInheritedTags = Array.Empty<string>();
- IncludeInheritedTags = Array.Empty<string>();
- ExcludeItemIds = Array.Empty<Guid>();
- ExcludeItemTypes = Array.Empty<BaseItemKind>();
- ExcludeTags = Array.Empty<string>();
- GenreIds = Array.Empty<Guid>();
- Genres = Array.Empty<string>();
+ ExcludeArtistIds = [];
+ ExcludeInheritedTags = [];
+ IncludeInheritedTags = [];
+ ExcludeItemIds = [];
+ ExcludeItemTypes = [];
+ ExcludeTags = [];
+ GenreIds = [];
+ Genres = [];
GroupByPresentationUniqueKey = true;
- ImageTypes = Array.Empty<ImageType>();
- IncludeItemTypes = Array.Empty<BaseItemKind>();
- ItemIds = Array.Empty<Guid>();
- MediaTypes = Array.Empty<MediaType>();
- OfficialRatings = Array.Empty<string>();
- OrderBy = Array.Empty<(ItemSortBy, SortOrder)>();
- PersonIds = Array.Empty<Guid>();
- PersonTypes = Array.Empty<string>();
- PresetViews = Array.Empty<CollectionType?>();
- SeriesStatuses = Array.Empty<SeriesStatus>();
- SourceTypes = Array.Empty<SourceType>();
- StudioIds = Array.Empty<Guid>();
- Tags = Array.Empty<string>();
- TopParentIds = Array.Empty<Guid>();
- TrailerTypes = Array.Empty<TrailerType>();
- VideoTypes = Array.Empty<VideoType>();
- Years = Array.Empty<int>();
+ ImageTypes = [];
+ IncludeItemTypes = [];
+ ItemIds = [];
+ OwnerIds = [];
+ ExtraTypes = [];
+ MediaTypes = [];
+ OfficialRatings = [];
+ OrderBy = [];
+ OwnerIds = [];
+ PersonIds = [];
+ PersonTypes = [];
+ PresetViews = [];
+ SeriesStatuses = [];
+ SourceTypes = [];
+ StudioIds = [];
+ Tags = [];
+ TopParentIds = [];
+ TrailerTypes = [];
+ VideoTypes = [];
+ Years = [];
SkipDeserialization = false;
}
@@ -109,6 +113,12 @@ namespace MediaBrowser.Controller.Entities
public bool? CollapseBoxSetItems { get; set; }
+ /// <summary>
+ /// Gets or sets the item types that should be collapsed into box sets.
+ /// When empty, all types are collapsed. When set, only items of these types are replaced by their parent box set.
+ /// </summary>
+ public BaseItemKind[] CollapseBoxSetItemTypes { get; set; } = [];
+
public string? NameStartsWithOrGreater { get; set; }
public string? NameStartsWith { get; set; }
@@ -133,6 +143,10 @@ namespace MediaBrowser.Controller.Entities
public Guid[] ItemIds { get; set; }
+ public Guid[] OwnerIds { get; set; }
+
+ public ExtraType[] ExtraTypes { get; set; }
+
public Guid[] ExcludeItemIds { get; set; }
public Guid? AdjacentTo { get; set; }
@@ -347,6 +361,12 @@ namespace MediaBrowser.Controller.Entities
public bool? HasOwnerId { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether to include items with an OwnerId
+ /// (additional parts, alternate versions) that are normally excluded from general queries.
+ /// </summary>
+ public bool IncludeOwnedItems { get; set; }
+
public bool? Is4K { get; set; }
public int? MaxHeight { get; set; }
@@ -363,6 +383,8 @@ namespace MediaBrowser.Controller.Entities
public bool SkipDeserialization { get; set; }
+ public bool IncludeExtras { get; set; }
+
public void SetUser(User user)
{
var maxRating = user.MaxParentalRatingScore;
@@ -388,5 +410,75 @@ namespace MediaBrowser.Controller.Entities
User = user;
}
+
+ public void ApplyFilters(ItemFilter[] filters)
+ {
+ static void ThrowConflictingFilters()
+ => throw new ArgumentException("Conflicting filters", nameof(filters));
+
+ foreach (var filter in filters)
+ {
+ switch (filter)
+ {
+ case ItemFilter.IsFolder:
+ if (filters.Contains(ItemFilter.IsNotFolder))
+ {
+ ThrowConflictingFilters();
+ }
+
+ IsFolder = true;
+ break;
+ case ItemFilter.IsNotFolder:
+ if (filters.Contains(ItemFilter.IsFolder))
+ {
+ ThrowConflictingFilters();
+ }
+
+ IsFolder = false;
+ break;
+ case ItemFilter.IsUnplayed:
+ if (filters.Contains(ItemFilter.IsPlayed))
+ {
+ ThrowConflictingFilters();
+ }
+
+ IsPlayed = false;
+ break;
+ case ItemFilter.IsPlayed:
+ if (filters.Contains(ItemFilter.IsUnplayed))
+ {
+ ThrowConflictingFilters();
+ }
+
+ IsPlayed = true;
+ break;
+ case ItemFilter.IsFavorite:
+ IsFavorite = true;
+ break;
+ case ItemFilter.IsResumable:
+ IsResumable = true;
+ break;
+ case ItemFilter.Likes:
+ if (filters.Contains(ItemFilter.Dislikes))
+ {
+ ThrowConflictingFilters();
+ }
+
+ IsLiked = true;
+ break;
+ case ItemFilter.Dislikes:
+ if (filters.Contains(ItemFilter.Likes))
+ {
+ ThrowConflictingFilters();
+ }
+
+ IsLiked = false;
+ break;
+ case ItemFilter.IsFavoriteOrLikes:
+ IsFavoriteOrLiked = true;
+ break;
+ }
+ }
+ }
}
}
diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs
index 203a16a668..e12ba22343 100644
--- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs
@@ -21,6 +21,8 @@ namespace MediaBrowser.Controller.Entities
ExcludePersonTypes = excludePersonTypes;
}
+ public int? StartIndex { get; set; }
+
/// <summary>
/// Gets or sets the maximum number of items the query should return.
/// </summary>
@@ -28,6 +30,8 @@ namespace MediaBrowser.Controller.Entities
public Guid ItemId { get; set; }
+ public Guid? ParentId { get; set; }
+
public IReadOnlyList<string> PersonTypes { get; }
public IReadOnlyList<string> ExcludePersonTypes { get; }
@@ -38,6 +42,12 @@ namespace MediaBrowser.Controller.Entities
public string NameContains { get; set; }
+ public string NameStartsWith { get; set; }
+
+ public string NameLessThan { get; set; }
+
+ public string NameStartsWithOrGreater { get; set; }
+
public User User { get; set; }
public bool? IsFavorite { get; set; }
diff --git a/MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs b/MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs
new file mode 100644
index 0000000000..7590ad7d36
--- /dev/null
+++ b/MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs
@@ -0,0 +1,31 @@
+using System;
+using MediaBrowser.Model.Configuration;
+
+namespace MediaBrowser.Controller.Entities;
+
+/// <summary>
+/// Event arguments for when library options are updated.
+/// </summary>
+public class LibraryOptionsUpdatedEventArgs : EventArgs
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LibraryOptionsUpdatedEventArgs"/> class.
+ /// </summary>
+ /// <param name="libraryPath">The path of the library whose options were updated.</param>
+ /// <param name="libraryOptions">The updated library options.</param>
+ public LibraryOptionsUpdatedEventArgs(string libraryPath, LibraryOptions libraryOptions)
+ {
+ LibraryPath = libraryPath;
+ LibraryOptions = libraryOptions;
+ }
+
+ /// <summary>
+ /// Gets the path of the library whose options were updated.
+ /// </summary>
+ public string LibraryPath { get; }
+
+ /// <summary>
+ /// Gets the updated library options.
+ /// </summary>
+ public LibraryOptions LibraryOptions { get; }
+}
diff --git a/MediaBrowser.Controller/Entities/LinkedChild.cs b/MediaBrowser.Controller/Entities/LinkedChild.cs
index 98e4f525f5..a3aa9dd0c9 100644
--- a/MediaBrowser.Controller/Entities/LinkedChild.cs
+++ b/MediaBrowser.Controller/Entities/LinkedChild.cs
@@ -3,7 +3,6 @@
#pragma warning disable CS1591
using System;
-using System.Globalization;
namespace MediaBrowser.Controller.Entities
{
@@ -13,10 +12,18 @@ namespace MediaBrowser.Controller.Entities
{
}
+ /// <summary>
+ /// Gets or sets the path.
+ /// </summary>
+ [Obsolete("Use ItemId instead")]
public string Path { get; set; }
public LinkedChildType Type { get; set; }
+ /// <summary>
+ /// Gets or sets the library item id.
+ /// </summary>
+ [Obsolete("Use ItemId instead")]
public string LibraryItemId { get; set; }
/// <summary>
@@ -28,18 +35,11 @@ namespace MediaBrowser.Controller.Entities
{
ArgumentNullException.ThrowIfNull(item);
- var child = new LinkedChild
+ return new LinkedChild
{
- Path = item.Path,
+ ItemId = item.Id,
Type = LinkedChildType.Manual
};
-
- if (string.IsNullOrEmpty(child.Path))
- {
- child.LibraryItemId = item.Id.ToString("N", CultureInfo.InvariantCulture);
- }
-
- return child;
}
}
}
diff --git a/MediaBrowser.Controller/Entities/LinkedChildComparer.cs b/MediaBrowser.Controller/Entities/LinkedChildComparer.cs
index 4f13ac61fe..8b611345f4 100644
--- a/MediaBrowser.Controller/Entities/LinkedChildComparer.cs
+++ b/MediaBrowser.Controller/Entities/LinkedChildComparer.cs
@@ -19,17 +19,34 @@ namespace MediaBrowser.Controller.Entities
public bool Equals(LinkedChild x, LinkedChild y)
{
- if (x.Type == y.Type)
+ if (x.Type != y.Type)
{
- return _fileSystem.AreEqual(x.Path, y.Path);
+ return false;
}
- return false;
+ // Compare by ItemId first (preferred)
+ if (x.ItemId.HasValue && y.ItemId.HasValue)
+ {
+ return x.ItemId.Value.Equals(y.ItemId.Value);
+ }
+
+#pragma warning disable CS0618 // Type or member is obsolete - fallback for shortcut/legacy comparison
+ // Fall back to Path comparison for shortcuts and legacy data
+ return _fileSystem.AreEqual(x.Path, y.Path);
+#pragma warning restore CS0618
}
public int GetHashCode(LinkedChild obj)
{
+ // Use ItemId for hash if available, otherwise fall back to legacy fields
+ if (obj.ItemId.HasValue && !obj.ItemId.Value.Equals(Guid.Empty))
+ {
+ return HashCode.Combine(obj.ItemId.Value, obj.Type);
+ }
+
+#pragma warning disable CS0618 // Type or member is obsolete - fallback for shortcut/legacy hashing
return ((obj.Path ?? string.Empty) + (obj.LibraryItemId ?? string.Empty) + obj.Type).GetHashCode(StringComparison.Ordinal);
+#pragma warning restore CS0618
}
}
}
diff --git a/MediaBrowser.Controller/Entities/LinkedChildType.cs b/MediaBrowser.Controller/Entities/LinkedChildType.cs
index 3bd260a102..5ce66a561f 100644
--- a/MediaBrowser.Controller/Entities/LinkedChildType.cs
+++ b/MediaBrowser.Controller/Entities/LinkedChildType.cs
@@ -13,6 +13,16 @@ namespace MediaBrowser.Controller.Entities
/// <summary>
/// Shortcut linked child.
/// </summary>
- Shortcut = 1
+ Shortcut = 1,
+
+ /// <summary>
+ /// Local alternate version (same item, different file path).
+ /// </summary>
+ LocalAlternateVersion = 2,
+
+ /// <summary>
+ /// Linked alternate version (different item ID).
+ /// </summary>
+ LinkedAlternateVersion = 3
}
}
diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs
index 3999c3e076..8216937cad 100644
--- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs
+++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs
@@ -37,9 +37,7 @@ namespace MediaBrowser.Controller.Entities.Movies
/// <inheritdoc />
[JsonIgnore]
- public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
- .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer)
- .ToArray();
+ public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray();
/// <summary>
/// Gets or sets the display order.
@@ -160,25 +158,68 @@ namespace MediaBrowser.Controller.Entities.Movies
return base.IsVisible(user, skipAllowedTagsCheck);
}
- if (base.IsVisible(user, skipAllowedTagsCheck))
+ if (!IsParentalAllowed(user, skipAllowedTagsCheck))
{
- if (LinkedChildren.Length == 0)
- {
- return true;
- }
+ return false;
+ }
+
+ if (LinkedChildren.Length == 0)
+ {
+ return true;
+ }
+
+ var userLibraryFolderIds = GetLibraryFolderIds(user);
+ var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds();
+
+ if (libraryFolderIds.Length == 0)
+ {
+ return true;
+ }
- var userLibraryFolderIds = GetLibraryFolderIds(user);
- var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds();
+ if (!userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i)))
+ {
+ return false;
+ }
- if (libraryFolderIds.Length == 0)
+ // If user has parental controls, hide the BoxSet when all children are restricted
+ if (user.MaxParentalRatingScore.HasValue)
+ {
+ var linkedItems = GetLinkedChildren();
+ if (linkedItems.Count > 0 && linkedItems.All(child => !child.IsParentalAllowed(user, true)))
{
- return true;
+ return false;
}
+ }
+
+ return true;
+ }
+
+ public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition)
+ {
+ if (IsLegacyBoxSet)
+ {
+ base.MarkPlayed(user, datePlayed, resetPosition);
+ return;
+ }
+
+ foreach (var item in GetLinkedChildren(user))
+ {
+ item.MarkPlayed(user, datePlayed, resetPosition);
+ }
+ }
- return userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i));
+ public override void MarkUnplayed(User user)
+ {
+ if (IsLegacyBoxSet)
+ {
+ base.MarkUnplayed(user);
+ return;
}
- return false;
+ foreach (var item in GetLinkedChildren(user))
+ {
+ item.MarkUnplayed(user);
+ }
}
public override bool IsVisibleStandalone(User user)
diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs
index 710b05e7f9..e8817a29cf 100644
--- a/MediaBrowser.Controller/Entities/Movies/Movie.cs
+++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs
@@ -4,13 +4,15 @@
using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
+using Microsoft.Extensions.Logging;
namespace MediaBrowser.Controller.Entities.Movies
{
@@ -28,9 +30,7 @@ namespace MediaBrowser.Controller.Entities.Movies
/// <inheritdoc />
[JsonIgnore]
- public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
- .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer)
- .ToArray();
+ public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray();
/// <summary>
/// Gets or sets the name of the TMDb collection.
diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs
index 6bdba36f9c..dbe6f94dfd 100644
--- a/MediaBrowser.Controller/Entities/TV/Episode.cs
+++ b/MediaBrowser.Controller/Entities/TV/Episode.cs
@@ -28,9 +28,7 @@ namespace MediaBrowser.Controller.Entities.TV
/// <inheritdoc />
[JsonIgnore]
- public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
- .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer)
- .ToArray();
+ public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray();
/// <summary>
/// Gets or sets the season in which it aired.
diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs
index b972ebaa6b..f70f7dfb4c 100644
--- a/MediaBrowser.Controller/Entities/TV/Season.cs
+++ b/MediaBrowser.Controller/Entities/TV/Season.cs
@@ -175,9 +175,7 @@ namespace MediaBrowser.Controller.Entities.TV
var user = query.User;
- Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager);
-
- var items = GetEpisodes(user, query.DtoOptions, true).Where(filter);
+ var items = UserViewBuilder.Filter(GetEpisodes(user, query.DtoOptions, true), user, query, UserDataManager, LibraryManager);
return PostFilterAndSort(items, query);
}
@@ -201,12 +199,17 @@ namespace MediaBrowser.Controller.Entities.TV
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
+ if (series is null)
+ {
+ return [];
+ }
+
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
}
public List<BaseItem> GetEpisodes()
{
- return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true);
+ return GetEpisodes(Series, null, null, new DtoOptions(true), true);
}
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs
index 6396631f99..952187c6e1 100644
--- a/MediaBrowser.Controller/Entities/TV/Series.cs
+++ b/MediaBrowser.Controller/Entities/TV/Series.cs
@@ -52,9 +52,7 @@ namespace MediaBrowser.Controller.Entities.TV
/// <inheritdoc />
[JsonIgnore]
- public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
- .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer)
- .ToArray();
+ public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray();
/// <summary>
/// Gets or sets the display order.
@@ -451,7 +449,7 @@ namespace MediaBrowser.Controller.Entities.TV
if (!currentSeasonNumber.HasValue && !seasonNumber.HasValue && parentSeason.LocationType == LocationType.Virtual)
{
- return true;
+ return episodeItem.Season is null or { LocationType: LocationType.Virtual };
}
var season = episodeItem.Season;
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index bed7554b19..cb05056601 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -16,9 +16,7 @@ using MediaBrowser.Controller.TV;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.Extensions.Logging;
-using Episode = MediaBrowser.Controller.Entities.TV.Episode;
using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider;
-using Series = MediaBrowser.Controller.Entities.TV.Series;
namespace MediaBrowser.Controller.Entities
{
@@ -140,7 +138,7 @@ namespace MediaBrowser.Controller.Entities
if (query.IncludeItemTypes.Length == 0)
{
- query.IncludeItemTypes = new[] { BaseItemKind.Movie };
+ query.IncludeItemTypes = [BaseItemKind.Movie];
}
return parent.QueryRecursive(query);
@@ -165,7 +163,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
- query.IncludeItemTypes = new[] { BaseItemKind.Movie };
+ query.IncludeItemTypes = [BaseItemKind.Movie];
return _libraryManager.GetItemsResult(query);
}
@@ -176,7 +174,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
- query.IncludeItemTypes = new[] { BaseItemKind.Series };
+ query.IncludeItemTypes = [BaseItemKind.Series];
return _libraryManager.GetItemsResult(query);
}
@@ -187,7 +185,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
- query.IncludeItemTypes = new[] { BaseItemKind.Episode };
+ query.IncludeItemTypes = [BaseItemKind.Episode];
return _libraryManager.GetItemsResult(query);
}
@@ -198,7 +196,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
- query.IncludeItemTypes = new[] { BaseItemKind.Movie };
+ query.IncludeItemTypes = [BaseItemKind.Movie];
return _libraryManager.GetItemsResult(query);
}
@@ -206,7 +204,7 @@ namespace MediaBrowser.Controller.Entities
private QueryResult<BaseItem> GetMovieCollections(User user, InternalItemsQuery query)
{
query.Parent = null;
- query.IncludeItemTypes = new[] { BaseItemKind.BoxSet };
+ query.IncludeItemTypes = [BaseItemKind.BoxSet];
query.SetUser(user);
query.Recursive = true;
@@ -215,25 +213,25 @@ namespace MediaBrowser.Controller.Entities
private QueryResult<BaseItem> GetMovieLatest(Folder parent, User user, InternalItemsQuery query)
{
- query.OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) };
+ query.OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)];
query.Recursive = true;
query.Parent = parent;
query.SetUser(user);
query.Limit = GetSpecialItemsLimit();
- query.IncludeItemTypes = new[] { BaseItemKind.Movie };
+ query.IncludeItemTypes = [BaseItemKind.Movie];
return ConvertToResult(_libraryManager.GetItemList(query));
}
private QueryResult<BaseItem> GetMovieResume(Folder parent, User user, InternalItemsQuery query)
{
- query.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) };
+ query.OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)];
query.IsResumable = true;
query.Recursive = true;
query.Parent = parent;
query.SetUser(user);
query.Limit = GetSpecialItemsLimit();
- query.IncludeItemTypes = new[] { BaseItemKind.Movie };
+ query.IncludeItemTypes = [BaseItemKind.Movie];
return ConvertToResult(_libraryManager.GetItemList(query));
}
@@ -247,7 +245,7 @@ namespace MediaBrowser.Controller.Entities
{
var genres = parent.QueryRecursive(new InternalItemsQuery(user)
{
- IncludeItemTypes = new[] { BaseItemKind.Movie },
+ IncludeItemTypes = [BaseItemKind.Movie],
Recursive = true,
EnableTotalRecordCount = false
}).Items
@@ -275,10 +273,10 @@ namespace MediaBrowser.Controller.Entities
{
query.Recursive = true;
query.Parent = queryParent;
- query.GenreIds = new[] { displayParent.Id };
+ query.GenreIds = [displayParent.Id];
query.SetUser(user);
- query.IncludeItemTypes = new[] { BaseItemKind.Movie };
+ query.IncludeItemTypes = [BaseItemKind.Movie];
return _libraryManager.GetItemsResult(query);
}
@@ -292,12 +290,12 @@ namespace MediaBrowser.Controller.Entities
if (query.IncludeItemTypes.Length == 0)
{
- query.IncludeItemTypes = new[]
- {
+ query.IncludeItemTypes =
+ [
BaseItemKind.Series,
BaseItemKind.Season,
BaseItemKind.Episode
- };
+ ];
}
return parent.QueryRecursive(query);
@@ -319,12 +317,12 @@ namespace MediaBrowser.Controller.Entities
private QueryResult<BaseItem> GetTvLatest(Folder parent, User user, InternalItemsQuery query)
{
- query.OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) };
+ query.OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)];
query.Recursive = true;
query.Parent = parent;
query.SetUser(user);
query.Limit = GetSpecialItemsLimit();
- query.IncludeItemTypes = new[] { BaseItemKind.Episode };
+ query.IncludeItemTypes = [BaseItemKind.Episode];
query.IsVirtualItem = false;
return ConvertToResult(_libraryManager.GetItemList(query));
@@ -332,7 +330,7 @@ namespace MediaBrowser.Controller.Entities
private QueryResult<BaseItem> GetTvNextUp(Folder parent, InternalItemsQuery query)
{
- var parentFolders = GetMediaFolders(parent, query.User, new[] { CollectionType.tvshows });
+ var parentFolders = GetMediaFolders(parent, query.User, [CollectionType.tvshows]);
var result = _tvSeriesManager.GetNextUp(
new NextUpQuery
@@ -349,13 +347,13 @@ namespace MediaBrowser.Controller.Entities
private QueryResult<BaseItem> GetTvResume(Folder parent, User user, InternalItemsQuery query)
{
- query.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) };
+ query.OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)];
query.IsResumable = true;
query.Recursive = true;
query.Parent = parent;
query.SetUser(user);
query.Limit = GetSpecialItemsLimit();
- query.IncludeItemTypes = new[] { BaseItemKind.Episode };
+ query.IncludeItemTypes = [BaseItemKind.Episode];
return ConvertToResult(_libraryManager.GetItemList(query));
}
@@ -366,7 +364,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
- query.IncludeItemTypes = new[] { BaseItemKind.Series };
+ query.IncludeItemTypes = [BaseItemKind.Series];
return _libraryManager.GetItemsResult(query);
}
@@ -375,7 +373,7 @@ namespace MediaBrowser.Controller.Entities
{
var genres = parent.QueryRecursive(new InternalItemsQuery(user)
{
- IncludeItemTypes = new[] { BaseItemKind.Series },
+ IncludeItemTypes = [BaseItemKind.Series],
Recursive = true,
EnableTotalRecordCount = false
}).Items
@@ -403,10 +401,10 @@ namespace MediaBrowser.Controller.Entities
{
query.Recursive = true;
query.Parent = queryParent;
- query.GenreIds = new[] { displayParent.Id };
+ query.GenreIds = [displayParent.Id];
query.SetUser(user);
- query.IncludeItemTypes = new[] { BaseItemKind.Series };
+ query.IncludeItemTypes = [BaseItemKind.Series];
return _libraryManager.GetItemsResult(query);
}
@@ -416,29 +414,54 @@ namespace MediaBrowser.Controller.Entities
InternalItemsQuery query)
where T : BaseItem
{
- items = items.Where(i => Filter(i, query.User, query, _userDataManager, _libraryManager));
+ var filtered = Filter(items, query.User, query, _userDataManager, _libraryManager);
- return PostFilterAndSort(items, null, query, _libraryManager);
+ return SortAndPage(filtered, null, query, _libraryManager);
}
- public static bool FilterItem(BaseItem item, InternalItemsQuery query)
- {
- return Filter(item, query.User, query, BaseItem.UserDataManager, BaseItem.LibraryManager);
- }
-
- public static QueryResult<BaseItem> PostFilterAndSort(
+ /// <summary>
+ /// Batch-aware filter that applies per-item checks.
+ /// </summary>
+ /// <param name="items">The items to filter.</param>
+ /// <param name="user">The user for filtering context.</param>
+ /// <param name="query">The query parameters.</param>
+ /// <param name="userDataManager">The user data manager.</param>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <returns>The filtered items.</returns>
+ public static IEnumerable<BaseItem> Filter(
IEnumerable<BaseItem> items,
- int? totalRecordLimit,
+ User user,
InternalItemsQuery query,
+ IUserDataManager userDataManager,
ILibraryManager libraryManager)
{
- // This must be the last filter
- if (!query.AdjacentTo.IsNullOrEmpty())
+ var filtered = items.Where(i => Filter(i, user, query, userDataManager, libraryManager));
+
+ if (query.IsPlayed.HasValue && user is not null)
{
- items = FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
+ var itemList = filtered.ToList();
+ var folderIds = itemList.OfType<Folder>().Select(f => f.Id).ToList();
+
+ if (folderIds.Count > 0)
+ {
+ var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
+ var isPlayedValue = query.IsPlayed.Value;
+
+ return itemList.Where(i =>
+ {
+ if (i.IsFolder && counts.TryGetValue(i.Id, out var c))
+ {
+ return (c.Total > 0 && c.Played == c.Total) == isPlayedValue;
+ }
+
+ return true;
+ });
+ }
+
+ return itemList;
}
- return SortAndPage(items, totalRecordLimit, query, libraryManager);
+ return filtered;
}
public static QueryResult<BaseItem> SortAndPage(
@@ -470,7 +493,12 @@ namespace MediaBrowser.Controller.Entities
itemsArray);
}
- public static bool Filter(BaseItem item, User user, InternalItemsQuery query, IUserDataManager userDataManager, ILibraryManager libraryManager)
+ private static bool Filter(
+ BaseItem item,
+ User user,
+ InternalItemsQuery query,
+ IUserDataManager userDataManager,
+ ILibraryManager libraryManager)
{
if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase))
{
@@ -558,35 +586,17 @@ namespace MediaBrowser.Controller.Entities
if (query.IsPlayed.HasValue)
{
- userData ??= userDataManager.GetUserData(user, item);
- if (item.IsPlayed(user, userData) != query.IsPlayed.Value)
- {
- return false;
- }
- }
-
- // Filter by Video3DFormat
- if (query.Is3D.HasValue)
- {
- var val = query.Is3D.Value;
- var video = item as Video;
-
- if (video is null || val != video.Video3DFormat.HasValue)
- {
- return false;
- }
- }
-
- /*
- * fuck - fix this
- if (query.IsHD.HasValue)
- {
- if (item.IsHD != query.IsHD.Value)
+ // Folder.IsPlayed() hits the DB per-item (N+1 queries).
+ // Folders are batch-filtered by the collection Filter() overload.
+ if (!item.IsFolder)
{
- return false;
+ userData ??= userDataManager.GetUserData(user, item);
+ if (item.IsPlayed(user, userData) != query.IsPlayed.Value)
+ {
+ return false;
+ }
}
}
- */
if (query.IsLocked.HasValue)
{
@@ -645,68 +655,6 @@ namespace MediaBrowser.Controller.Entities
}
}
- if (query.HasOfficialRating.HasValue)
- {
- var filterValue = query.HasOfficialRating.Value;
-
- var hasValue = !string.IsNullOrEmpty(item.OfficialRating);
-
- if (hasValue != filterValue)
- {
- return false;
- }
- }
-
- if (query.IsPlaceHolder.HasValue)
- {
- var filterValue = query.IsPlaceHolder.Value;
-
- var isPlaceHolder = false;
-
- if (item is ISupportsPlaceHolders hasPlaceHolder)
- {
- isPlaceHolder = hasPlaceHolder.IsPlaceHolder;
- }
-
- if (isPlaceHolder != filterValue)
- {
- return false;
- }
- }
-
- if (query.HasSpecialFeature.HasValue)
- {
- var filterValue = query.HasSpecialFeature.Value;
-
- if (item is IHasSpecialFeatures movie)
- {
- var ok = filterValue
- ? movie.SpecialFeatureIds.Count > 0
- : movie.SpecialFeatureIds.Count == 0;
-
- if (!ok)
- {
- return false;
- }
- }
- else
- {
- return false;
- }
- }
-
- if (query.HasSubtitles.HasValue)
- {
- var val = query.HasSubtitles.Value;
-
- var video = item as Video;
-
- if (video is null || val != video.HasSubtitles)
- {
- return false;
- }
- }
-
if (query.HasParentalRating.HasValue)
{
var val = query.HasParentalRating.Value;
@@ -734,66 +682,12 @@ namespace MediaBrowser.Controller.Entities
}
}
- if (query.HasTrailer.HasValue)
- {
- var val = query.HasTrailer.Value;
- var trailerCount = 0;
-
- if (item is IHasTrailers hasTrailers)
- {
- trailerCount = hasTrailers.GetTrailerCount();
- }
-
- var ok = val ? trailerCount > 0 : trailerCount == 0;
-
- if (!ok)
- {
- return false;
- }
- }
-
- if (query.HasThemeSong.HasValue)
- {
- var filterValue = query.HasThemeSong.Value;
-
- var themeCount = item.GetThemeSongs(user).Count;
- var ok = filterValue ? themeCount > 0 : themeCount == 0;
-
- if (!ok)
- {
- return false;
- }
- }
-
- if (query.HasThemeVideo.HasValue)
- {
- var filterValue = query.HasThemeVideo.Value;
-
- var themeCount = item.GetThemeVideos(user).Count;
- var ok = filterValue ? themeCount > 0 : themeCount == 0;
-
- if (!ok)
- {
- return false;
- }
- }
-
// Apply genre filter
if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
- // Filter by VideoType
- if (query.VideoTypes.Length > 0)
- {
- var video = item as Video;
- if (video is null || !query.VideoTypes.Contains(video.VideoType))
- {
- return false;
- }
- }
-
if (query.ImageTypes.Length > 0 && !query.ImageTypes.Any(item.HasImage))
{
return false;
@@ -912,30 +806,6 @@ namespace MediaBrowser.Controller.Entities
}
}
- if (query.SeriesStatuses.Length > 0)
- {
- var ok = new[] { item }.OfType<Series>().Any(p => p.Status.HasValue && query.SeriesStatuses.Contains(p.Status.Value));
- if (!ok)
- {
- return false;
- }
- }
-
- if (query.AiredDuringSeason.HasValue)
- {
- var episode = item as Episode;
-
- if (episode is null)
- {
- return false;
- }
-
- if (!Series.FilterEpisodesBySeason(new[] { episode }, query.AiredDuringSeason.Value, true).Any())
- {
- return false;
- }
- }
-
if (query.ExcludeItemIds.Contains(item.Id))
{
return false;
@@ -989,7 +859,7 @@ namespace MediaBrowser.Controller.Entities
return GetMediaFolders(user, viewTypes);
}
- return new BaseItem[] { parent };
+ return [parent];
}
private UserView GetUserViewWithName(CollectionType? type, string sortName, BaseItem parent)
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index 1043029c6e..80bcd62dcd 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -19,6 +19,7 @@ using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
namespace MediaBrowser.Controller.Entities
{
@@ -40,7 +41,7 @@ namespace MediaBrowser.Controller.Entities
}
[JsonIgnore]
- public string PrimaryVersionId { get; set; }
+ public Guid? PrimaryVersionId { get; set; }
public string[] AdditionalParts { get; set; }
@@ -160,7 +161,7 @@ namespace MediaBrowser.Controller.Entities
public bool IsStacked => AdditionalParts.Length > 0;
[JsonIgnore]
- public override bool HasLocalAlternateVersions => LocalAlternateVersions.Length > 0;
+ public override bool HasLocalAlternateVersions => LibraryManager.GetLocalAlternateVersionIds(this).Any();
public static IRecordingsManager RecordingsManager { get; set; }
@@ -253,14 +254,17 @@ namespace MediaBrowser.Controller.Entities
private int GetMediaSourceCount(HashSet<Guid> callstack = null)
{
callstack ??= new();
- if (!string.IsNullOrEmpty(PrimaryVersionId))
+ if (PrimaryVersionId.HasValue)
{
- var item = LibraryManager.GetItemById(PrimaryVersionId);
+ var item = LibraryManager.GetItemById(PrimaryVersionId.Value);
if (item is Video video)
{
if (callstack.Contains(video.Id))
{
- return video.LinkedAlternateVersions.Length + video.LocalAlternateVersions.Length + 1;
+ // Count alternate versions using LibraryManager
+ var linkedCount = LibraryManager.GetLinkedAlternateVersions(video).Count();
+ var localCount = LibraryManager.GetLocalAlternateVersionIds(video).Count();
+ return linkedCount + localCount + 1;
}
callstack.Add(video.Id);
@@ -268,7 +272,10 @@ namespace MediaBrowser.Controller.Entities
}
}
- return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1;
+ // Count alternate versions using LibraryManager
+ var linkedVersionCount = LibraryManager.GetLinkedAlternateVersions(this).Count();
+ var localVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count();
+ return linkedVersionCount + localVersionCount + 1;
}
public override List<string> GetUserDataKeys()
@@ -310,25 +317,17 @@ namespace MediaBrowser.Controller.Entities
return list;
}
- public void SetPrimaryVersionId(string id)
+ public void SetPrimaryVersionId(Guid? id)
{
- if (string.IsNullOrEmpty(id))
- {
- PrimaryVersionId = null;
- }
- else
- {
- PrimaryVersionId = id;
- }
-
+ PrimaryVersionId = id;
PresentationUniqueKey = CreatePresentationUniqueKey();
}
public override string CreatePresentationUniqueKey()
{
- if (!string.IsNullOrEmpty(PrimaryVersionId))
+ if (PrimaryVersionId.HasValue)
{
- return PrimaryVersionId;
+ return PrimaryVersionId.Value.ToString("N", CultureInfo.InvariantCulture);
}
return base.CreatePresentationUniqueKey();
@@ -364,11 +363,6 @@ namespace MediaBrowser.Controller.Entities
return AdditionalParts.Select(i => LibraryManager.GetNewItemId(i, typeof(Video)));
}
- public IEnumerable<Guid> GetLocalAlternateVersionIds()
- {
- return LocalAlternateVersions.Select(i => LibraryManager.GetNewItemId(i, typeof(Video)));
- }
-
private string GetUserDataKey(string providerId)
{
var key = providerId + "-" + ExtraType.ToString().ToLowerInvariant();
@@ -382,15 +376,6 @@ namespace MediaBrowser.Controller.Entities
return key;
}
- public IEnumerable<Video> GetLinkedAlternateVersions()
- {
- return LinkedAlternateVersions
- .Select(GetLinkedChild)
- .Where(i => i is not null)
- .OfType<Video>()
- .OrderBy(i => i.SortName);
- }
-
/// <summary>
/// Gets the additional parts.
/// </summary>
@@ -436,10 +421,21 @@ namespace MediaBrowser.Controller.Entities
{
var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
+ // Clean up LocalAlternateVersions - remove paths that no longer exist
+ if (LocalAlternateVersions.Length > 0)
+ {
+ var validPaths = LocalAlternateVersions.Where(FileSystem.FileExists).ToArray();
+ if (validPaths.Length != LocalAlternateVersions.Length)
+ {
+ LocalAlternateVersions = validPaths;
+ hasChanges = true;
+ }
+ }
+
if (IsStacked)
{
var tasks = AdditionalParts
- .Select(i => RefreshMetadataForOwnedVideo(options, true, i, cancellationToken));
+ .Select(i => RefreshMetadataForOwnedVideo(options, true, i, typeof(Video), cancellationToken));
await Task.WhenAll(tasks).ConfigureAwait(false);
}
@@ -449,30 +445,134 @@ namespace MediaBrowser.Controller.Entities
// The additional parts won't have additional parts themselves
if (IsFileProtocol && SupportsOwnedItems)
{
- if (!IsStacked)
- {
- RefreshLinkedAlternateVersions();
+ // Check if LinkedChildren are in sync before processing
+ var existingVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count();
+ var tasks = LocalAlternateVersions
+ .Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken));
- var tasks = LocalAlternateVersions
- .Select(i => RefreshMetadataForOwnedVideo(options, false, i, cancellationToken));
+ await Task.WhenAll(tasks).ConfigureAwait(false);
- await Task.WhenAll(tasks).ConfigureAwait(false);
+ if (existingVersionCount != LocalAlternateVersions.Length)
+ {
+ hasChanges = true;
}
}
return hasChanges;
}
- private void RefreshLinkedAlternateVersions()
+ private async Task RefreshMetadataForVersions(
+ MetadataRefreshOptions options,
+ bool copyTitleMetadata,
+ string path,
+ CancellationToken cancellationToken)
+ {
+ // Ensure the alternate version exists with the correct type (e.g. Movie, not Video)
+ // before refreshing. This must happen here rather than in RefreshMetadataForOwnedVideo
+ // because that method is also used for stacked parts which should keep their resolved type.
+ var id = LibraryManager.GetNewItemId(path, GetType());
+ if (LibraryManager.GetItemById(id) is not Video && FileSystem.FileExists(path))
+ {
+ var parentFolder = GetParent() as Folder;
+ var collectionType = LibraryManager.GetContentType(this);
+ var altVideo = LibraryManager.ResolveAlternateVersion(path, GetType(), parentFolder, collectionType);
+ if (altVideo is not null)
+ {
+ altVideo.OwnerId = Id;
+ altVideo.SetPrimaryVersionId(Id);
+ LibraryManager.CreateItem(altVideo, GetParent());
+ }
+ }
+
+ await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false);
+
+ // Create LinkedChild entry for this local alternate version
+ // This ensures the relationship exists in the database even if the alternate version
+ // was created after the primary video was first saved
+ if (LibraryManager.GetItemById(id) is Video video)
+ {
+ LibraryManager.UpsertLinkedChild(Id, video.Id, LinkedChildType.LocalAlternateVersion);
+
+ // Ensure PrimaryVersionId is set for existing alternate versions that may not have it
+ if (!video.PrimaryVersionId.HasValue)
+ {
+ video.SetPrimaryVersionId(Id);
+ await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private new Task RefreshMetadataForOwnedVideo(
+ MetadataRefreshOptions options,
+ bool copyTitleMetadata,
+ string path,
+ CancellationToken cancellationToken)
+ => RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, GetType(), cancellationToken);
+
+ private async Task RefreshMetadataForOwnedVideo(
+ MetadataRefreshOptions options,
+ bool copyTitleMetadata,
+ string path,
+ Type itemType,
+ CancellationToken cancellationToken)
{
- foreach (var child in LinkedAlternateVersions)
+ var newOptions = new MetadataRefreshOptions(options)
{
- // Reset the cached value
- if (child.ItemId.IsNullOrEmpty())
+ SearchResult = null
+ };
+
+ var id = LibraryManager.GetNewItemId(path, itemType);
+
+ // Check if the file still exists
+ if (!FileSystem.FileExists(path))
+ {
+ // File was removed - clean up any orphaned database entry
+ if (LibraryManager.GetItemById(id) is Video orphanedVideo && orphanedVideo.OwnerId.Equals(Id))
{
- child.ItemId = null;
+ Logger.LogInformation("Owned video file no longer exists, removing orphaned item: {Path}", path);
+ LibraryManager.DeleteItem(orphanedVideo, new DeleteOptions { DeleteFileLocation = false });
}
+
+ return;
}
+
+ if (LibraryManager.GetItemById(id) is not Video video)
+ {
+ var parentFolder = GetParent() as Folder;
+ var collectionType = LibraryManager.GetContentType(this);
+ video = LibraryManager.ResolvePath(
+ FileSystem.GetFileSystemInfo(path),
+ parentFolder,
+ collectionType: collectionType) as Video;
+
+ if (video is null)
+ {
+ return;
+ }
+
+ // Ensure parts use the expected base type (e.g. Video, not Movie)
+ if (video.GetType() != itemType && Activator.CreateInstance(itemType) is Video correctVideo)
+ {
+ correctVideo.Path = video.Path;
+ correctVideo.Name = video.Name;
+ correctVideo.VideoType = video.VideoType;
+ correctVideo.ProductionYear = video.ProductionYear;
+ correctVideo.ExtraType = video.ExtraType;
+ video = correctVideo;
+ }
+
+ video.Id = id;
+ video.OwnerId = Id;
+ LibraryManager.CreateItem(video, parentFolder);
+ newOptions.ForceSave = true;
+ }
+
+ if (video.OwnerId.IsEmpty())
+ {
+ video.OwnerId = Id;
+ }
+
+ await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -480,7 +580,7 @@ namespace MediaBrowser.Controller.Entities
{
await base.UpdateToRepositoryAsync(updateReason, cancellationToken).ConfigureAwait(false);
- var localAlternates = GetLocalAlternateVersionIds()
+ var localAlternates = LibraryManager.GetLocalAlternateVersionIds(this)
.Select(i => LibraryManager.GetItemById(i))
.Where(i => i is not null);
@@ -537,22 +637,24 @@ namespace MediaBrowser.Controller.Entities
(this, MediaSourceType.Default)
};
- list.AddRange(GetLinkedAlternateVersions().Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
+ list.AddRange(
+ LibraryManager.GetLinkedAlternateVersions(this)
+ .Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
- if (!string.IsNullOrEmpty(PrimaryVersionId))
+ if (PrimaryVersionId.HasValue)
{
- if (LibraryManager.GetItemById(PrimaryVersionId) is Video primary)
+ if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary)
{
var existingIds = list.Select(i => i.Item1.Id).ToList();
list.Add((primary, MediaSourceType.Grouping));
- list.AddRange(primary.GetLinkedAlternateVersions().Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
+ list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
}
}
var localAlternates = list
.SelectMany(i =>
{
- return i.Item1 is Video video ? video.GetLocalAlternateVersionIds() : Enumerable.Empty<Guid>();
+ return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>();
})
.Select(LibraryManager.GetItemById)
.Where(i => i is not null)