Merge remote-tracking branch 'upstream/master' into epg-fixes

This commit is contained in:
Shadowghost
2026-05-04 21:26:26 +02:00
233 changed files with 31008 additions and 4377 deletions

View File

@@ -13,12 +13,19 @@ namespace MediaBrowser.Controller.Chapters;
/// </summary>
public interface IChapterManager
{
/// <summary>
/// Gets a value indicating whether the specified item type is supported for chapter operations.
/// </summary>
/// <param name="item">The item to check.</param>
/// <returns><c>true</c> if the item type supports chapters; otherwise, <c>false</c>.</returns>
bool Supports(BaseItem item);
/// <summary>
/// Saves the chapters.
/// </summary>
/// <param name="video">The video.</param>
/// <param name="item">The item.</param>
/// <param name="chapters">The set of chapters.</param>
void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters);
void SaveChapters(BaseItem item, IReadOnlyList<ChapterInfo> chapters);
/// <summary>
/// Gets a single chapter of a BaseItem on a specific index.

View File

@@ -36,8 +36,14 @@ namespace MediaBrowser.Controller.Dto
/// <param name="options">The options.</param>
/// <param name="user">The user.</param>
/// <param name="owner">The owner.</param>
/// <param name="skipVisibilityCheck">Skip redundant visibility check if items are already filtered.</param>
/// <returns>The <see cref="IReadOnlyList{T}"/> of <see cref="BaseItemDto"/>.</returns>
IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null);
IReadOnlyList<BaseItemDto> GetBaseItemDtos(
IReadOnlyList<BaseItem> items,
DtoOptions options,
User? user = null,
BaseItem? owner = null,
bool skipVisibilityCheck = false);
/// <summary>
/// Gets the item by name dto.

View File

@@ -106,7 +106,6 @@ namespace MediaBrowser.Controller.Entities
ImageInfos = Array.Empty<ItemImageInfo>();
ProductionLocations = Array.Empty<string>();
RemoteTrailers = Array.Empty<MediaUrl>();
ExtraIds = Array.Empty<Guid>();
UserData = [];
}
@@ -397,8 +396,6 @@ namespace MediaBrowser.Controller.Entities
public int Height { get; set; }
public Guid[] ExtraIds { get; set; }
/// <summary>
/// Gets the primary image path.
/// </summary>
@@ -491,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; }
@@ -1340,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))
{
@@ -1358,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;
}
@@ -1401,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)
{
@@ -1415,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()
@@ -1433,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;
}
@@ -1673,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;
}
@@ -1687,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;
@@ -1803,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);
@@ -1821,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;
@@ -1835,6 +1902,7 @@ namespace MediaBrowser.Controller.Entities
return null;
}
#pragma warning restore CS0618
/// <summary>
/// Adds a studio to the item.
@@ -2415,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)
{
@@ -2654,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>
@@ -2667,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()

View File

@@ -44,6 +44,11 @@ namespace MediaBrowser.Controller.Entities
PhysicalFolderIds = Array.Empty<Guid>();
}
/// <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>
@@ -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()

View File

@@ -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);
@@ -452,8 +485,18 @@ namespace MediaBrowser.Controller.Entities
// That's all the new and changed ones - now see if any have been removed and need cleanup
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
var shouldRemove = !IsRoot || allowRemoveRoot;
var actuallyRemoved = new List<BaseItem>();
// 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)
@@ -464,6 +507,40 @@ 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);
@@ -480,6 +557,106 @@ 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)
@@ -716,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
@@ -755,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;
@@ -809,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();
@@ -990,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
@@ -1012,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);
@@ -1026,29 +1066,11 @@ 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);
}
if (!string.IsNullOrEmpty(query.NameStartsWith))
{
items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase));
}
if (!string.IsNullOrEmpty(query.NameLessThan))
{
items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.InvariantCultureIgnoreCase) == 1);
}
#pragma warning restore CA1309
// This must be the last filter
if (!query.AdjacentTo.IsNullOrEmpty())
{
items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
// 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);
}
var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
@@ -1062,6 +1084,26 @@ namespace MediaBrowser.Controller.Entities
return result;
}
private static IEnumerable<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query)
{
if (!string.IsNullOrWhiteSpace(query.NameStartsWith))
{
items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(query.NameStartsWithOrGreater))
{
items = items.Where(i => string.Compare(i.SortName, query.NameStartsWithOrGreater, StringComparison.OrdinalIgnoreCase) >= 0);
}
if (!string.IsNullOrWhiteSpace(query.NameLessThan))
{
items = items.Where(i => string.Compare(i.SortName, query.NameLessThan, StringComparison.OrdinalIgnoreCase) < 0);
}
return items;
}
private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded(
IEnumerable<BaseItem> items,
InternalItemsQuery query,
@@ -1167,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)
@@ -1418,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;
@@ -1525,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;
@@ -1636,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;
@@ -1680,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);
@@ -1712,12 +1838,6 @@ namespace MediaBrowser.Controller.Entities
}
}
foreach (var child in LinkedChildren)
{
// Reset the cached value
child.ItemId = null;
}
return false;
}
@@ -1795,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)
{
Recursive = true,
IsFolder = false,
IsVirtualItem = false,
EnableTotalRecordCount = true,
Limit = 0,
IsPlayed = false,
DtoOptions = new DtoOptions(false)
{
EnableImages = false
}
}).TotalRecordCount;
dto.UnplayedItemCount = unplayedQueryResult;
if (itemDto?.RecursiveItemCount > 0)
{
var unplayedPercentage = ((double)unplayedQueryResult / itemDto.RecursiveItemCount.Value) * 100;
dto.PlayedPercentage = 100 - unplayedPercentage;
dto.Played = dto.PlayedPercentage.Value >= 100;
// Use batch-fetched counts (avoids N+1 queries)
(playedCount, totalCount) = precomputedCounts.Value;
}
else
{
dto.Played = (dto.UnplayedItemCount ?? 0) == 0;
// Fall back to per-item query when no batch data is available
var query = new InternalItemsQuery(user);
if (LinkedChildren.Length > 0)
{
(playedCount, totalCount) = ItemCountService.GetPlayedAndTotalCountFromLinkedChildren(query, Id);
}
else
{
(playedCount, totalCount) = ItemCountService.GetPlayedAndTotalCount(query, Id);
}
}
if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount))
{
itemDto.RecursiveItemCount = totalCount;
}
if (SupportsPlayedStatus)
{
var unplayedCount = totalCount - playedCount;
dto.UnplayedItemCount = unplayedCount;
if (totalCount > 0)
{
dto.PlayedPercentage = playedCount / (double)totalCount * 100;
dto.Played = playedCount >= totalCount;
}
else
{
dto.Played = true;
}
}
}
}

View File

@@ -18,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;
}
@@ -110,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; }
@@ -134,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; }
@@ -348,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; }
@@ -364,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;

View File

@@ -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; }
}

View File

@@ -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;
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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;
}
var userLibraryFolderIds = GetLibraryFolderIds(user);
var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds();
if (libraryFolderIds.Length == 0)
{
return true;
}
return userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i));
return false;
}
return false;
if (LinkedChildren.Length == 0)
{
return true;
}
var userLibraryFolderIds = GetLibraryFolderIds(user);
var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds();
if (libraryFolderIds.Length == 0)
{
return true;
}
if (!userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i)))
{
return false;
}
// 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 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);
}
}
public override void MarkUnplayed(User user)
{
if (IsLegacyBoxSet)
{
base.MarkUnplayed(user);
return;
}
foreach (var item in GetLinkedChildren(user))
{
item.MarkUnplayed(user);
}
}
public override bool IsVisibleStandalone(User user)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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.

View File

@@ -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,36 +586,18 @@ namespace MediaBrowser.Controller.Entities
if (query.IsPlayed.HasValue)
{
userData ??= userDataManager.GetUserData(user, item);
if (item.IsPlayed(user, userData) != query.IsPlayed.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;
}
}
}
// Filter by Video3DFormat
if (query.Is3D.HasValue)
{
var val = query.Is3D.Value;
var video = item as Video;
if (video is null || val != video.Video3DFormat.HasValue)
{
return false;
}
}
/*
* fuck - fix this
if (query.IsHD.HasValue)
{
if (item.IsHD != query.IsHD.Value)
{
return false;
}
}
*/
if (query.IsLocked.HasValue)
{
var val = query.IsLocked.Value;
@@ -645,68 +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)

View File

@@ -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)
// Check if LinkedChildren are in sync before processing
var existingVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count();
var tasks = LocalAlternateVersions
.Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken));
await Task.WhenAll(tasks).ConfigureAwait(false);
if (existingVersionCount != LocalAlternateVersions.Length)
{
RefreshLinkedAlternateVersions();
var tasks = LocalAlternateVersions
.Select(i => RefreshMetadataForOwnedVideo(options, false, i, cancellationToken));
await Task.WhenAll(tasks).ConfigureAwait(false);
hasChanges = true;
}
}
return hasChanges;
}
private void RefreshLinkedAlternateVersions()
private async Task RefreshMetadataForVersions(
MetadataRefreshOptions options,
bool copyTitleMetadata,
string path,
CancellationToken cancellationToken)
{
foreach (var child in LinkedAlternateVersions)
// 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))
{
// Reset the cached value
if (child.ItemId.IsNullOrEmpty())
var parentFolder = GetParent() as Folder;
var collectionType = LibraryManager.GetContentType(this);
var altVideo = LibraryManager.ResolveAlternateVersion(path, GetType(), parentFolder, collectionType);
if (altVideo is not null)
{
child.ItemId = 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)
{
var newOptions = new MetadataRefreshOptions(options)
{
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))
{
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)

View File

@@ -20,6 +20,7 @@ using MediaBrowser.Model.IO;
using MediaBrowser.Model.Querying;
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
using Genre = MediaBrowser.Controller.Entities.Genre;
using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
using Person = MediaBrowser.Controller.Entities.Person;
namespace MediaBrowser.Controller.Library
@@ -58,11 +59,29 @@ namespace MediaBrowser.Controller.Library
/// <param name="fileInfo">The file information.</param>
/// <param name="parent">The parent.</param>
/// <param name="directoryService">An instance of <see cref="IDirectoryService"/>.</param>
/// <param name="collectionType">The collection type of the library containing this item.</param>
/// <returns>BaseItem.</returns>
BaseItem? ResolvePath(
FileSystemMetadata fileInfo,
Folder? parent = null,
IDirectoryService? directoryService = null);
IDirectoryService? directoryService = null,
CollectionType? collectionType = null);
/// <summary>
/// Resolves a video file as an alternate version of a primary video, ensuring the result
/// has the same concrete type as the primary (e.g. Movie instead of generic Video).
/// Also cleans up any existing item with the wrong type from a previous scan.
/// </summary>
/// <param name="path">The file path of the alternate version.</param>
/// <param name="expectedVideoType">The expected concrete type (same as the primary video).</param>
/// <param name="parent">The parent folder.</param>
/// <param name="collectionType">The collection type of the library.</param>
/// <returns>A correctly-typed Video, or null if resolution fails.</returns>
Video? ResolveAlternateVersion(
string path,
Type expectedVideoType,
Folder? parent,
CollectionType? collectionType);
/// <summary>
/// Resolves a set of files into a list of BaseItem.
@@ -158,6 +177,13 @@ namespace MediaBrowser.Controller.Library
/// <returns>Task.</returns>
Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false);
/// <summary>
/// Clears the cached ignore rule directory lookups.
/// Call this before triggering a library scan or item refresh to ensure
/// any changes to .ignore files are picked up.
/// </summary>
void ClearIgnoreRuleCache();
Task UpdateImagesAsync(BaseItem item, bool forceUpdate = false);
/// <summary>
@@ -213,6 +239,30 @@ namespace MediaBrowser.Controller.Library
/// <returns>IEnumerable{System.String}.</returns>
Task<IEnumerable<Video>> GetIntros(BaseItem item, User user);
/// <summary>
/// Gets the IDs of local alternate versions for a video.
/// Local alternate versions are alternate quality versions at different file paths.
/// </summary>
/// <param name="video">The video item.</param>
/// <returns>Enumerable of alternate version item IDs.</returns>
IEnumerable<Guid> GetLocalAlternateVersionIds(Video video);
/// <summary>
/// Gets the linked alternate versions for a video.
/// Linked alternate versions are different items representing the same content (e.g., Director's Cut).
/// </summary>
/// <param name="video">The video item.</param>
/// <returns>Enumerable of linked Video items.</returns>
IEnumerable<Video> GetLinkedAlternateVersions(Video video);
/// <summary>
/// Creates or updates a LinkedChild entry linking a parent to a child item.
/// </summary>
/// <param name="parentId">The parent item ID.</param>
/// <param name="childId">The child item ID.</param>
/// <param name="childType">The type of linked child relationship.</param>
void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType);
/// <summary>
/// Adds the parts.
/// </summary>
@@ -348,8 +398,9 @@ namespace MediaBrowser.Controller.Library
/// Deletes items that are not having any children like Actors.
/// </summary>
/// <param name="items">Items to delete.</param>
/// <param name="deleteSourceFiles">Whether to delete source media files on disk. Defaults to false.</param>
/// <remarks>In comparison to <see cref="DeleteItem(BaseItem, DeleteOptions, BaseItem, bool)"/> this method skips a lot of steps assuming there are no children to recusively delete nor does it define the special handling for channels and alike.</remarks>
public void DeleteItemsUnsafeFast(IEnumerable<BaseItem> items);
public void DeleteItemsUnsafeFast(IReadOnlyCollection<BaseItem> items, bool deleteSourceFiles = false);
/// <summary>
/// Deletes the item.
@@ -514,7 +565,7 @@ namespace MediaBrowser.Controller.Library
/// </summary>
/// <param name="query">The query.</param>
/// <returns>List&lt;Person&gt;.</returns>
IReadOnlyList<Person> GetPeopleItems(InternalPeopleQuery query);
QueryResult<BaseItem> GetPeopleItems(InternalPeopleQuery query);
/// <summary>
/// Updates the people.
@@ -600,6 +651,20 @@ namespace MediaBrowser.Controller.Library
/// <returns>List of series presentation keys.</returns>
IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents, DateTime dateCutoff);
/// <summary>
/// Gets next up episodes for multiple series in a single batched query.
/// </summary>
/// <param name="query">The query filter.</param>
/// <param name="seriesKeys">The series presentation unique keys to query.</param>
/// <param name="includeSpecials">Whether to include specials for aired episode order sorting.</param>
/// <param name="includeWatchedForRewatching">Whether to include watched episodes for rewatching mode.</param>
/// <returns>A dictionary mapping series key to batch result.</returns>
IReadOnlyDictionary<string, MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult> GetNextUpEpisodesBatch(
InternalItemsQuery query,
IReadOnlyList<string> seriesKeys,
bool includeSpecials,
bool includeWatchedForRewatching);
/// <summary>
/// Gets the items result.
/// </summary>
@@ -649,6 +714,42 @@ namespace MediaBrowser.Controller.Library
ItemCounts GetItemCounts(InternalItemsQuery query);
/// <summary>
/// Gets item counts for a "by-name" item using an optimized query path.
/// </summary>
/// <param name="kind">The kind of the name item.</param>
/// <param name="id">The ID of the name item.</param>
/// <param name="relatedItemKinds">The item kinds to count.</param>
/// <param name="user">The user for access filtering.</param>
/// <returns>The item counts grouped by type.</returns>
ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, User? user);
/// <summary>
/// Batch-fetches child counts for multiple parent folders.
/// Returns the count of immediate children (non-recursive) for each parent.
/// </summary>
/// <param name="parentIds">The list of parent folder IDs.</param>
/// <param name="userId">The user ID for access filtering.</param>
/// <returns>Dictionary mapping parent ID to child count.</returns>
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
/// <summary>
/// Batch-fetches played and total counts for multiple folder items.
/// Avoids N+1 queries when building DTOs for lists of folder items.
/// </summary>
/// <param name="folderIds">The list of folder item IDs.</param>
/// <param name="user">The user for access filtering and played status.</param>
/// <returns>Dictionary mapping folder ID to (Played count, Total count).</returns>
Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user);
/// <summary>
/// Configures the query with user access settings including TopParentIds for library access.
/// Call this before passing a query to methods that need user access filtering.
/// </summary>
/// <param name="query">The query to configure.</param>
/// <param name="user">The user to configure access for.</param>
void ConfigureUserAccess(InternalItemsQuery query, User user);
Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason);
BaseItem GetParentItem(Guid? parentId, Guid? userId);
@@ -667,5 +768,21 @@ namespace MediaBrowser.Controller.Library
/// <param name="virtualFolderPath">The path to the virtualfolder.</param>
/// <param name="pathInfo">The new virtualfolder.</param>
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo);
/// <summary>
/// Re-routes LinkedChildren references from one child to another.
/// Used when video versions change to maintain playlist/BoxSet integrity.
/// </summary>
/// <param name="fromChildId">The child ID to re-route from.</param>
/// <param name="toChildId">The child ID to re-route to.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task RerouteLinkedChildReferencesAsync(Guid fromChildId, Guid toChildId);
/// <summary>
/// Gets legacy query filters for filtering UI.
/// </summary>
/// <param name="query">The query filter.</param>
/// <returns>Aggregated filter values.</returns>
QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query);
}
}

View File

@@ -54,6 +54,14 @@ namespace MediaBrowser.Controller.Library
/// <returns>User data dto.</returns>
UserItemDataDto? GetUserDataDto(BaseItem item, User user);
/// <summary>
/// Gets user data for multiple items in a single batch operation.
/// </summary>
/// <param name="items">The items to get user data for.</param>
/// <param name="user">The user.</param>
/// <returns>A dictionary mapping item IDs to their user data.</returns>
Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user);
/// <summary>
/// Gets the user data dto.
/// </summary>

View File

@@ -1621,13 +1621,25 @@ namespace MediaBrowser.Controller.MediaEncoding
mbbrcOpt = " -mbbrc 1";
}
// Some less powerful H.264 HW decoders require strict CPB size
// So bufsize optimizations should not be applied to them
int factor = 2;
var codec = state.ActualOutputVideoCodec;
var level = state.GetRequestedLevel(codec);
if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)
&& double.TryParse(level, CultureInfo.InvariantCulture, out double requestedLevel)
&& requestedLevel < 51)
{
factor = 1;
}
// Set (maxrate == bitrate + 1) to trigger VBR for better bitrate allocation
// Set (rc_init_occupancy == 2 * bitrate) and (bufsize == 4 * bitrate) to deal with drastic scene changes
// Use long arithmetic and clamp to int.MaxValue to prevent int32 overflow
// (e.g. bitrate * 4 wraps to a negative value for bitrates above ~537 million)
int qsvMaxrate = (int)Math.Min((long)bitrate + 1, int.MaxValue);
int qsvInitOcc = (int)Math.Min((long)bitrate * 2, int.MaxValue);
int qsvBufsize = (int)Math.Min((long)bitrate * 4, int.MaxValue);
int qsvInitOcc = (int)Math.Min((long)bitrate * 1 * factor, int.MaxValue);
int qsvBufsize = (int)Math.Min((long)bitrate * 2 * factor, int.MaxValue);
return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {qsvMaxrate} -rc_init_occupancy {qsvInitOcc} -bufsize {qsvBufsize}");
}

View File

@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Provides item counting and played-status query operations.
/// </summary>
public interface IItemCountService
{
/// <summary>
/// Gets the count of items matching the filter.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>The item count.</returns>
int GetCount(InternalItemsQuery filter);
/// <summary>
/// Gets item counts grouped by type.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>The item counts by type.</returns>
ItemCounts GetItemCounts(InternalItemsQuery filter);
/// <summary>
/// Gets item counts for a "by-name" item using an optimized query.
/// </summary>
/// <param name="kind">The kind of the name item.</param>
/// <param name="id">The ID of the name item.</param>
/// <param name="relatedItemKinds">The item kinds to count.</param>
/// <param name="accessFilter">A pre-configured query with user access filtering settings.</param>
/// <returns>The item counts grouped by type.</returns>
ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter);
/// <summary>
/// Gets the count of played items that are descendants of the specified ancestor.
/// </summary>
/// <param name="filter">The query filter containing user access settings.</param>
/// <param name="ancestorId">The ancestor item id.</param>
/// <returns>The count of played descendant items.</returns>
int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId);
/// <summary>
/// Gets the total count of items that are descendants of the specified ancestor.
/// </summary>
/// <param name="filter">The query filter containing user access settings.</param>
/// <param name="ancestorId">The ancestor item id.</param>
/// <returns>The total count of descendant items.</returns>
int GetTotalCount(InternalItemsQuery filter, Guid ancestorId);
/// <summary>
/// Gets both the played count and total count of descendant items.
/// </summary>
/// <param name="filter">The query filter containing user access settings.</param>
/// <param name="ancestorId">The ancestor item id.</param>
/// <returns>A tuple containing (Played count, Total count).</returns>
(int Played, int Total) GetPlayedAndTotalCount(InternalItemsQuery filter, Guid ancestorId);
/// <summary>
/// Gets both the played count and total count from linked children.
/// </summary>
/// <param name="filter">The query filter containing user access settings.</param>
/// <param name="parentId">The parent item id.</param>
/// <returns>A tuple containing (Played count, Total count).</returns>
(int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId);
/// <summary>
/// Batch-fetches played and total counts for multiple folder items.
/// </summary>
/// <param name="folderIds">The list of folder item IDs to get counts for.</param>
/// <param name="user">The user for access filtering and played status.</param>
/// <returns>Dictionary mapping folder ID to (Played count, Total count).</returns>
Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user);
/// <summary>
/// Batch-fetches child counts for multiple parent folders.
/// </summary>
/// <param name="parentIds">The list of parent folder IDs.</param>
/// <param name="userId">The user ID for access filtering.</param>
/// <returns>Dictionary mapping parent ID to child count.</returns>
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Provides item persistence operations (save, delete, update).
/// </summary>
public interface IItemPersistenceService
{
/// <summary>
/// Deletes items by their IDs.
/// </summary>
/// <param name="ids">The IDs to delete.</param>
void DeleteItem(params IReadOnlyList<Guid> ids);
/// <summary>
/// Saves items to the database.
/// </summary>
/// <param name="items">The items to save.</param>
/// <param name="cancellationToken">The cancellation token.</param>
void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken);
/// <summary>
/// Saves image info for an item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default);
/// <summary>
/// Reattaches user data entries to the correct item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
/// <summary>
/// Updates inherited values.
/// </summary>
void UpdateInheritedValues();
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Linq;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Provides shared query-building methods used by extracted item services.
/// Implemented by <c>BaseItemRepository</c>.
/// </summary>
public interface IItemQueryHelpers
{
/// <summary>
/// Translates an <see cref="InternalItemsQuery"/> into EF Core filter expressions.
/// </summary>
/// <param name="baseQuery">The base queryable to filter.</param>
/// <param name="context">The database context.</param>
/// <param name="filter">The query filter.</param>
/// <returns>The filtered queryable.</returns>
IQueryable<BaseItemEntity> TranslateQuery(
IQueryable<BaseItemEntity> baseQuery,
JellyfinDbContext context,
InternalItemsQuery filter);
/// <summary>
/// Prepares a base query for items from the context.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="filter">The query filter.</param>
/// <returns>The prepared queryable.</returns>
IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter);
/// <summary>
/// Applies user access filtering (library access, parental controls, tags) to a query.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="baseQuery">The base queryable to filter.</param>
/// <param name="filter">The query filter containing access settings.</param>
/// <returns>The access-filtered queryable.</returns>
IQueryable<BaseItemEntity> ApplyAccessFiltering(
JellyfinDbContext context,
IQueryable<BaseItemEntity> baseQuery,
InternalItemsQuery filter);
/// <summary>
/// Applies navigation property includes to a query based on filter options.
/// </summary>
/// <param name="dbQuery">The queryable to apply navigations to.</param>
/// <param name="filter">The query filter.</param>
/// <returns>The queryable with navigation includes.</returns>
IQueryable<BaseItemEntity> ApplyNavigations(
IQueryable<BaseItemEntity> dbQuery,
InternalItemsQuery filter);
/// <summary>
/// Applies ordering to a query based on filter options.
/// </summary>
/// <param name="query">The queryable to order.</param>
/// <param name="filter">The query filter.</param>
/// <param name="context">The database context.</param>
/// <returns>The ordered queryable.</returns>
IQueryable<BaseItemEntity> ApplyOrder(
IQueryable<BaseItemEntity> query,
InternalItemsQuery filter,
JellyfinDbContext context);
/// <summary>
/// Builds a query for descendants of an ancestor with user access filtering applied.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="filter">The query filter.</param>
/// <param name="ancestorId">The ancestor item ID.</param>
/// <returns>The filtered descendant queryable.</returns>
IQueryable<BaseItemEntity> BuildAccessFilteredDescendantsQuery(
JellyfinDbContext context,
InternalItemsQuery filter,
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.
/// </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(
JellyfinDbContext context,
IQueryable<Guid> folderIds,
User user);
/// <summary>
/// Deserializes a <see cref="BaseItemEntity"/> into a <see cref="BaseItem"/>.
/// </summary>
/// <param name="entity">The database entity.</param>
/// <param name="skipDeserialization">Whether to skip JSON deserialization.</param>
/// <returns>The deserialized item, or null.</returns>
BaseItem? DeserializeBaseItem(BaseItemEntity entity, bool skipDeserialization = false);
/// <summary>
/// Prepares a filter query by adjusting limits and virtual item settings.
/// </summary>
/// <param name="query">The query to prepare.</param>
void PrepareFilterQuery(InternalItemsQuery query);
}

View File

@@ -1,15 +1,11 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Querying;
@@ -20,29 +16,6 @@ namespace MediaBrowser.Controller.Persistence;
/// </summary>
public interface IItemRepository
{
/// <summary>
/// Deletes the item.
/// </summary>
/// <param name="ids">The identifier to delete.</param>
void DeleteItem(params IReadOnlyList<Guid> ids);
/// <summary>
/// Saves the items.
/// </summary>
/// <param name="items">The items.</param>
/// <param name="cancellationToken">The cancellation token.</param>
void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken);
Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default);
/// <summary>
/// Reattaches the user data to the item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous reattachment operation.</returns>
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
/// <summary>
/// Retrieves the item.
/// </summary>
@@ -79,43 +52,6 @@ public interface IItemRepository
/// <returns>List&lt;BaseItem&gt;.</returns>
IReadOnlyList<BaseItem> GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType);
/// <summary>
/// Gets the list of series presentation keys for next up.
/// </summary>
/// <param name="filter">The query.</param>
/// <param name="dateCutoff">The minimum date for a series to have been most recently watched.</param>
/// <returns>The list of keys.</returns>
IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff);
/// <summary>
/// Updates the inherited values.
/// </summary>
void UpdateInheritedValues();
int GetCount(InternalItemsQuery filter);
ItemCounts GetItemCounts(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery filter);
IReadOnlyList<string> GetMusicGenreNames();
IReadOnlyList<string> GetStudioNames();
IReadOnlyList<string> GetGenreNames();
IReadOnlyList<string> GetAllArtistNames();
/// <summary>
/// Checks if an item has been persisted to the database.
/// </summary>
@@ -124,18 +60,84 @@ public interface IItemRepository
Task<bool> ItemExistsAsync(Guid id);
/// <summary>
/// Gets a value indicating wherever all children of the requested Id has been played.
/// Gets genres with item counts.
/// </summary>
/// <param name="user">The userdata to check against.</param>
/// <param name="id">The Top id to check.</param>
/// <param name="recursive">Whever the check should be done recursive. Warning expensive operation.</param>
/// <returns>A value indicating whever all children has been played.</returns>
bool GetIsPlayed(User user, Guid id, bool recursive);
/// <param name="filter">The query filter.</param>
/// <returns>The genres and their item counts.</returns>
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter);
/// <summary>
/// Gets all artist matches from the db.
/// Gets music genres with item counts.
/// </summary>
/// <param name="artistNames">The names of the artists.</param>
/// <returns>A map of the artist name and the potential matches.</returns>
IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames);
/// <param name="filter">The query filter.</param>
/// <returns>The music genres and their item counts.</returns>
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter);
/// <summary>
/// Gets studios with item counts.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>The studios and their item counts.</returns>
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery filter);
/// <summary>
/// Gets artists with item counts.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>The artists and their item counts.</returns>
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery filter);
/// <summary>
/// Gets album artists with item counts.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>The album artists and their item counts.</returns>
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery filter);
/// <summary>
/// Gets all artists with item counts.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>All artists and their item counts.</returns>
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery filter);
/// <summary>
/// Gets all music genre names.
/// </summary>
/// <returns>The list of music genre names.</returns>
IReadOnlyList<string> GetMusicGenreNames();
/// <summary>
/// Gets all studio names.
/// </summary>
/// <returns>The list of studio names.</returns>
IReadOnlyList<string> GetStudioNames();
/// <summary>
/// Gets all genre names.
/// </summary>
/// <returns>The list of genre names.</returns>
IReadOnlyList<string> GetGenreNames();
/// <summary>
/// Gets all artist names.
/// </summary>
/// <returns>The list of artist names.</returns>
IReadOnlyList<string> GetAllArtistNames();
/// <summary>
/// Gets legacy query filters aggregated from the database.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>Aggregated filter values.</returns>
QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery filter);
/// <summary>
/// Gets whether all children of the requested item have been played.
/// </summary>
/// <param name="user">The user to check against.</param>
/// <param name="id">The top item id to check.</param>
/// <param name="recursive">Whether the check should be done recursively.</param>
/// <returns>A value indicating whether all children have been played.</returns>
bool GetIsPlayed(User user, Guid id, bool recursive);
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Controller.Entities.Audio;
using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Provides linked children query and manipulation operations.
/// </summary>
public interface ILinkedChildrenService
{
/// <summary>
/// Gets the IDs of linked children for the specified parent.
/// </summary>
/// <param name="parentId">The parent item ID.</param>
/// <param name="childType">Optional child type filter.</param>
/// <returns>List of child item IDs.</returns>
IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null);
/// <summary>
/// Gets all artist matches from the database.
/// </summary>
/// <param name="artistNames">The names of the artists.</param>
/// <returns>A map of the artist name and the potential matches.</returns>
IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames);
/// <summary>
/// Gets parent IDs that reference the specified child with LinkedChildType.Manual.
/// </summary>
/// <param name="childId">The child item ID.</param>
/// <returns>List of parent IDs that reference the child.</returns>
IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId);
/// <summary>
/// Updates LinkedChildren references from one child to another.
/// </summary>
/// <param name="fromChildId">The child ID to re-route from.</param>
/// <param name="toChildId">The child ID to re-route to.</param>
/// <returns>List of parent item IDs whose LinkedChildren were modified.</returns>
IReadOnlyList<Guid> RerouteLinkedChildren(Guid fromChildId, Guid toChildId);
/// <summary>
/// Creates or updates a LinkedChild entry.
/// </summary>
/// <param name="parentId">The parent item ID.</param>
/// <param name="childId">The child item ID.</param>
/// <param name="childType">The type of linked child relationship.</param>
void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType);
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Provides next-up episode query operations.
/// </summary>
public interface INextUpService
{
/// <summary>
/// Gets the list of series presentation keys for next up.
/// </summary>
/// <param name="filter">The query.</param>
/// <param name="dateCutoff">The minimum date for a series to have been most recently watched.</param>
/// <returns>The list of keys.</returns>
IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff);
/// <summary>
/// Gets next up episodes for multiple series in a single batched query.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <param name="seriesKeys">The series presentation unique keys to query.</param>
/// <param name="includeSpecials">Whether to include specials.</param>
/// <param name="includeWatchedForRewatching">Whether to include watched episodes for rewatching mode.</param>
/// <returns>A dictionary mapping series key to batch result.</returns>
IReadOnlyDictionary<string, NextUpEpisodeBatchResult> GetNextUpEpisodesBatch(
InternalItemsQuery filter,
IReadOnlyList<string> seriesKeys,
bool includeSpecials,
bool includeWatchedForRewatching);
}

View File

@@ -1,13 +1,15 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Querying;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Provides methods for accessing Peoples.
/// </summary>
public interface IPeopleRepository
{
/// <summary>
@@ -15,7 +17,7 @@ public interface IPeopleRepository
/// </summary>
/// <param name="filter">The query.</param>
/// <returns>The list of people matching the filter.</returns>
IReadOnlyList<PersonInfo> GetPeople(InternalPeopleQuery filter);
QueryResult<PersonInfo> GetPeople(InternalPeopleQuery filter);
/// <summary>
/// Updates the people.

View File

@@ -0,0 +1,38 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Result of a batched NextUp query for a single series.
/// </summary>
public sealed class NextUpEpisodeBatchResult
{
/// <summary>
/// Gets or sets the last watched episode (highest season/episode that is played).
/// </summary>
public BaseItem? LastWatched { get; set; }
/// <summary>
/// Gets or sets the next unwatched episode after the last watched position.
/// </summary>
public BaseItem? NextUp { get; set; }
/// <summary>
/// Gets or sets specials that may air between episodes.
/// Only populated when includeSpecials is true.
/// </summary>
public IReadOnlyList<BaseItem>? Specials { get; set; }
/// <summary>
/// Gets or sets the last watched episode for rewatching mode (most recently played).
/// Only populated when includeWatchedForRewatching is true.
/// </summary>
public BaseItem? LastWatchedForRewatching { get; set; }
/// <summary>
/// Gets or sets the next played episode for rewatching mode.
/// Only populated when includeWatchedForRewatching is true.
/// </summary>
public BaseItem? NextPlayedForRewatching { get; set; }
}

View File

@@ -3,6 +3,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MediaBrowser.Model.IO;
@@ -26,7 +27,20 @@ namespace MediaBrowser.Controller.Providers
public FileSystemMetadata[] GetFileSystemEntries(string path)
{
return _cache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem);
return _cache.GetOrAdd(
path,
static (p, fileSystem) =>
{
try
{
return fileSystem.GetFileSystemEntries(p).ToArray();
}
catch (DirectoryNotFoundException)
{
return [];
}
},
_fileSystem);
}
public List<FileSystemMetadata> GetDirectories(string path)
@@ -98,7 +112,20 @@ namespace MediaBrowser.Controller.Providers
_filePathCache.TryRemove(path, out _);
}
var filePaths = _filePathCache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFilePaths(p).ToList(), _fileSystem);
var filePaths = _filePathCache.GetOrAdd(
path,
static (p, fileSystem) =>
{
try
{
return fileSystem.GetFilePaths(p).ToList();
}
catch (DirectoryNotFoundException)
{
return [];
}
},
_fileSystem);
if (sort)
{