mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-27 19:08:27 +01:00
Merge remote-tracking branch 'upstream/master' into epg-fixes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Person>.</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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
86
MediaBrowser.Controller/Persistence/IItemCountService.cs
Normal file
86
MediaBrowser.Controller/Persistence/IItemCountService.cs
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
107
MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs
Normal file
107
MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs
Normal 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);
|
||||
}
|
||||
@@ -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<BaseItem>.</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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
33
MediaBrowser.Controller/Persistence/INextUpService.cs
Normal file
33
MediaBrowser.Controller/Persistence/INextUpService.cs
Normal 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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user