< Summary - Jellyfin

Information
Class: MediaBrowser.Controller.Entities.Folder
Assembly: MediaBrowser.Controller
File(s): /srv/git/jellyfin/MediaBrowser.Controller/Entities/Folder.cs
Line coverage
43%
Covered lines: 362
Uncovered lines: 474
Coverable lines: 836
Total lines: 2125
Line coverage: 43.3%
Branch coverage
33%
Covered branches: 172
Total branches: 518
Branch coverage: 33.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/10/2026 - 12:15:26 AM Line coverage: 38% (304/800) Branch coverage: 28.7% (146/508) Total lines: 20195/20/2026 - 12:15:44 AM Line coverage: 38% (304/800) Branch coverage: 26.5% (135/508) Total lines: 20196/7/2026 - 12:16:09 AM Line coverage: 38% (305/801) Branch coverage: 26.7% (136/508) Total lines: 20227/6/2026 - 12:16:28 AM Line coverage: 38.4% (310/806) Branch coverage: 26.7% (136/508) Total lines: 20318/2/2026 - 12:17:28 AM Line coverage: 38.6% (312/808) Branch coverage: 26.7% (136/508) Total lines: 20478/3/2026 - 12:16:46 AM Line coverage: 38.8% (312/804) Branch coverage: 26.9% (136/504) Total lines: 203910/29/2026 - 12:17:11 AM Line coverage: 43.3% (362/836) Branch coverage: 33.2% (172/518) Total lines: 2125 5/10/2026 - 12:15:26 AM Line coverage: 38% (304/800) Branch coverage: 28.7% (146/508) Total lines: 20195/20/2026 - 12:15:44 AM Line coverage: 38% (304/800) Branch coverage: 26.5% (135/508) Total lines: 20196/7/2026 - 12:16:09 AM Line coverage: 38% (305/801) Branch coverage: 26.7% (136/508) Total lines: 20227/6/2026 - 12:16:28 AM Line coverage: 38.4% (310/806) Branch coverage: 26.7% (136/508) Total lines: 20318/2/2026 - 12:17:28 AM Line coverage: 38.6% (312/808) Branch coverage: 26.7% (136/508) Total lines: 20478/3/2026 - 12:16:46 AM Line coverage: 38.8% (312/804) Branch coverage: 26.9% (136/504) Total lines: 203910/29/2026 - 12:17:11 AM Line coverage: 43.3% (362/836) Branch coverage: 33.2% (172/518) Total lines: 2125

Coverage delta

Coverage delta 7 -7

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%
get_LinkedChildren()100%11100%
set_LinkedChildren(...)100%11100%
get_SupportsThemeMedia()100%210%
get_IsPreSorted()100%210%
get_IsPhysicalRoot()100%11100%
get_SupportsInheritedParentImages()100%210%
get_SupportsPlayedStatus()100%11100%
get_IsFolder()100%11100%
get_IsDisplayedAsFolder()100%210%
get_SupportsCumulativeRunTimeTicks()100%11100%
get_SupportsDateLastMediaAdded()100%11100%
get_FileNameWithoutExtension()50%2266.66%
get_Children()100%22100%
set_Children(...)100%11100%
get_RecursiveChildren()100%210%
get_SupportsShortcutChildren()100%11100%
get_FilterLinkedChildrenPerUser()100%11100%
get_SupportsOwnedItems()100%22100%
get_SupportsUserDataFromChildren()50%361657.14%
CanDelete()50%2266.66%
RequiresRefresh()25%4475%
AddChild(...)0%4260%
IsVisible(...)75%171266.66%
LoadChildren()100%11100%
ReleaseCachedChildren()83.33%66100%
GetRefreshProgress()100%210%
ValidateChildren(...)100%210%
ValidateChildren(...)100%11100%
GetActualChildrenDictionary()37.5%10866.66%
ValidateChildrenInternal()83.33%6688.88%
IsLibraryFolderAccessible(...)80%101083.33%
ValidateChildrenInternal2()46.36%185411047.57%
PromoteToPrimaryVersionAsync()0%2040%
RefreshMetadataRecursive()100%11100%
RefreshAllMetadataForContainer()0%620%
RefreshChildMetadata()62.5%14854.54%
ValidateSubFolders()100%11100%
RunTasks()100%11100%
GetNonCachedChildren(...)100%11100%
GetCachedChildren()100%11100%
GetChildrenForValidation()100%11100%
GetChildCount(...)0%2040%
GetRecursiveChildCount(...)100%210%
QueryRecursive(...)57.14%181472.72%
QueryWithPostFiltering(...)0%7280%
SortItemsByRequest(...)100%210%
GetItems(...)0%4260%
GetItemList(...)16.66%13642.85%
GetItemsInternal(...)33.33%32610.71%
PostFilterAndSort(...)0%620%
ApplyNameFilter(...)0%4260%
CollapseBoxSetItemsIfNeeded(...)0%506220%
CollapseBoxSetItems(...)25%1822435%
SetCollapseBoxSetItemTypes(...)0%4260%
AllowBoxSetCollapsing(...)0%4692680%
GetChildren(...)50%4487.5%
GetChildren(...)100%11100%
GetEligibleChildrenForRecursiveChildren(...)100%11100%
AddChildren(...)42.85%171475%
AddChildrenFromCollection(...)57.14%151480.95%
GetRecursiveChildren(...)100%210%
GetRecursiveChildren()100%210%
GetRecursiveChildren(...)100%210%
GetRecursiveChildren(...)100%210%
GetRecursiveChildren(...)100%210%
AddChildrenToList(...)0%342180%
GetLinkedChildren()100%11100%
GetLinkedChildren(...)100%22100%
ContainsLinkedChildByItemId(...)0%110100%
GetLinkedChildren(...)9.09%406227.4%
GetLinkedChildrenInfos()100%210%
ResolveLinkedChildren(...)79.16%242492%
RefreshedOwnedItems()75%4485.71%
RefreshLinkedChildren(...)75%4490.24%
MarkPlayed(...)0%110100%
MarkUnplayed(...)0%620%
IsPlayed(...)100%210%
IsUnplayed(...)100%210%
FillUserDataDtoValues(...)45%272073.68%
GetProgress(...)100%11100%

File(s)

/srv/git/jellyfin/MediaBrowser.Controller/Entities/Folder.cs

#LineLine coverage
 1#nullable disable
 2
 3#pragma warning disable CA1002, CA1721, CA1819, CS1591
 4
 5using System;
 6using System.Collections.Generic;
 7using System.Collections.Immutable;
 8using System.IO;
 9using System.Linq;
 10using System.Security;
 11using System.Text.Json.Serialization;
 12using System.Threading;
 13using System.Threading.Tasks;
 14using J2N.Collections.Generic.Extensions;
 15using Jellyfin.Data;
 16using Jellyfin.Data.Enums;
 17using Jellyfin.Database.Implementations.Entities;
 18using Jellyfin.Database.Implementations.Enums;
 19using Jellyfin.Extensions;
 20using MediaBrowser.Controller.Channels;
 21using MediaBrowser.Controller.Collections;
 22using MediaBrowser.Controller.Configuration;
 23using MediaBrowser.Controller.Dto;
 24using MediaBrowser.Controller.Entities.Audio;
 25using MediaBrowser.Controller.Entities.Movies;
 26using MediaBrowser.Controller.Library;
 27using MediaBrowser.Controller.LibraryTaskScheduler;
 28using MediaBrowser.Controller.Providers;
 29using MediaBrowser.Model.Dto;
 30using MediaBrowser.Model.IO;
 31using MediaBrowser.Model.Querying;
 32using Microsoft.Extensions.Logging;
 33using Episode = MediaBrowser.Controller.Entities.TV.Episode;
 34using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum;
 35using Season = MediaBrowser.Controller.Entities.TV.Season;
 36using Series = MediaBrowser.Controller.Entities.TV.Series;
 37
 38namespace MediaBrowser.Controller.Entities
 39{
 40    /// <summary>
 41    /// Class Folder.
 42    /// </summary>
 43    public class Folder : BaseItem
 44    {
 45        private IEnumerable<BaseItem> _children;
 282446        private LinkedChild[] _linkedChildren = [];
 47
 48        public static IUserViewManager UserViewManager { get; set; }
 49
 50        public static ILimitedConcurrencyLibraryScheduler LimitedConcurrencyLibraryScheduler { get; set; }
 51
 52        /// <summary>
 53        /// Gets or sets a value indicating whether this instance is root.
 54        /// </summary>
 55        /// <value><c>true</c> if this instance is root; otherwise, <c>false</c>.</value>
 56        public bool IsRoot { get; set; }
 57
 58        /// <summary>
 59        /// Gets or sets the linked children.
 60        /// </summary>
 61        [JsonIgnore]
 62        public LinkedChild[] LinkedChildren
 63        {
 35064            get => _linkedChildren;
 65            set
 66            {
 28267                _linkedChildren = value;
 68
 69                // Assigning the collection means the caller knows the complete set of links.
 28270                LinkedChildrenLoaded = true;
 28271            }
 72        }
 73
 74        /// <summary>
 75        /// Gets a value indicating whether <see cref="LinkedChildren"/> holds the stored set of links.
 76        /// </summary>
 77        /// <remarks>
 78        /// An unloaded instance carries an empty array that means "unknown", not "no children" —
 79        /// persisting it would delete every link the item has.
 80        /// </remarks>
 81        [JsonIgnore]
 82        public bool LinkedChildrenLoaded { get; private set; }
 83
 84        [JsonIgnore]
 85        public DateTime? DateLastMediaAdded { get; set; }
 86
 87        [JsonIgnore]
 088        public override bool SupportsThemeMedia => true;
 89
 90        [JsonIgnore]
 091        public virtual bool IsPreSorted => false;
 92
 93        [JsonIgnore]
 494        public virtual bool IsPhysicalRoot => false;
 95
 96        [JsonIgnore]
 097        public override bool SupportsInheritedParentImages => true;
 98
 99        [JsonIgnore]
 6100        public override bool SupportsPlayedStatus => true;
 101
 102        /// <summary>
 103        /// Gets a value indicating whether this instance is folder.
 104        /// </summary>
 105        /// <value><c>true</c> if this instance is folder; otherwise, <c>false</c>.</value>
 106        [JsonIgnore]
 3470107        public override bool IsFolder => true;
 108
 109        [JsonIgnore]
 0110        public override bool IsDisplayedAsFolder => true;
 111
 112        [JsonIgnore]
 153113        public virtual bool SupportsCumulativeRunTimeTicks => false;
 114
 115        [JsonIgnore]
 106116        public virtual bool SupportsDateLastMediaAdded => false;
 117
 118        [JsonIgnore]
 119        public override string FileNameWithoutExtension
 120        {
 121            get
 122            {
 582123                if (IsFileProtocol)
 124                {
 582125                    return System.IO.Path.GetFileName(Path);
 126                }
 127
 0128                return null;
 129            }
 130        }
 131
 132        /// <summary>
 133        /// Gets or Sets the actual children.
 134        /// </summary>
 135        /// <value>The actual children.</value>
 136        [JsonIgnore]
 137        public virtual IEnumerable<BaseItem> Children
 138        {
 1495139            get => _children ??= LoadChildren();
 336140            set => _children = value;
 141        }
 142
 143        /// <summary>
 144        /// Gets thread-safe access to all recursive children of this folder - without regard to user.
 145        /// </summary>
 146        /// <value>The recursive children.</value>
 147        [JsonIgnore]
 0148        public IEnumerable<BaseItem> RecursiveChildren => GetRecursiveChildren();
 149
 150        [JsonIgnore]
 34151        protected virtual bool SupportsShortcutChildren => false;
 152
 4153        protected virtual bool FilterLinkedChildrenPerUser => false;
 154
 155        [JsonIgnore]
 180156        protected override bool SupportsOwnedItems => base.SupportsOwnedItems || SupportsShortcutChildren;
 157
 158        [JsonIgnore]
 159        public virtual bool SupportsUserDataFromChildren
 160        {
 161            get
 162            {
 163                // These are just far too slow.
 18164                if (this is ICollectionFolder)
 165                {
 6166                    return false;
 167                }
 168
 12169                if (this is UserView)
 170                {
 2171                    return false;
 172                }
 173
 10174                if (this is UserRootFolder)
 175                {
 6176                    return false;
 177                }
 178
 4179                if (this is Channel)
 180                {
 0181                    return false;
 182                }
 183
 4184                if (SourceType != SourceType.Library)
 185                {
 0186                    return false;
 187                }
 188
 4189                if (this is IItemByName)
 190                {
 0191                    if (this is not IHasDualAccess hasDualAccess || hasDualAccess.IsAccessedByName)
 192                    {
 0193                        return false;
 194                    }
 195                }
 196
 4197                return true;
 198            }
 199        }
 200
 201        public static ICollectionManager CollectionManager { get; set; }
 202
 203        public override bool CanDelete()
 204        {
 6205            if (IsRoot)
 206            {
 6207                return false;
 208            }
 209
 0210            return base.CanDelete();
 211        }
 212
 213        public override bool RequiresRefresh()
 214        {
 106215            var baseResult = base.RequiresRefresh();
 216
 106217            if (SupportsCumulativeRunTimeTicks && !RunTimeTicks.HasValue)
 218            {
 0219                baseResult = true;
 220            }
 221
 106222            return baseResult;
 223        }
 224
 225        /// <summary>
 226        /// Adds the child.
 227        /// </summary>
 228        /// <param name="item">The item.</param>
 229        /// <exception cref="InvalidOperationException">Unable to add  + item.Name.</exception>
 230        public void AddChild(BaseItem item)
 231        {
 0232            item.SetParent(this);
 233
 0234            if (item.Id.IsEmpty())
 235            {
 0236                item.Id = LibraryManager.GetNewItemId(item.Path, item.GetType());
 237            }
 238
 0239            if (item.DateCreated == DateTime.MinValue)
 240            {
 0241                item.DateCreated = DateTime.UtcNow;
 242            }
 243
 0244            if (item.DateModified == DateTime.MinValue)
 245            {
 0246                item.DateModified = DateTime.UtcNow;
 247            }
 248
 0249            LibraryManager.CreateItem(item, this);
 0250        }
 251
 252        public override bool IsVisible(User user, bool skipAllowedTagsCheck = false)
 253        {
 8254            if (this is ICollectionFolder && this is not BasePluginFolder)
 255            {
 1256                var blockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders);
 1257                if (blockedMediaFolders.Length > 0)
 258                {
 0259                    if (blockedMediaFolders.Contains(Id))
 260                    {
 0261                        return false;
 262                    }
 263                }
 264                else
 265                {
 1266                    if (!user.HasPermission(PermissionKind.EnableAllFolders)
 1267                        && !user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders).Contains(Id))
 268                    {
 0269                        return false;
 270                    }
 271                }
 272            }
 273
 8274            return base.IsVisible(user, skipAllowedTagsCheck);
 275        }
 276
 277        /// <summary>
 278        /// Loads our children.  Validation will occur externally.
 279        /// We want this synchronous.
 280        /// </summary>
 281        /// <returns>Returns children.</returns>
 282        protected virtual IReadOnlyList<BaseItem> LoadChildren()
 283        {
 284            // logger.LogDebug("Loading children from {0} {1} {2}", GetType().Name, Id, Path);
 285            // just load our children from the repo - the library will be validated and maintained in other processes
 187286            return GetCachedChildren();
 287        }
 288
 289        /// <summary>
 290        /// Drops the children this folder has materialised, and the ones held by every folder below
 291        /// it, without loading anything that is not already in memory.
 292        /// </summary>
 293        public void ReleaseCachedChildren()
 294        {
 295            // Cleared before descending, so a folder already on the way down is not walked twice.
 91296            var children = _children;
 91297            _children = null;
 298
 91299            if (children is null)
 300            {
 84301                return;
 302            }
 303
 26304            foreach (var child in children)
 305            {
 6306                (child as Folder)?.ReleaseCachedChildren();
 307            }
 7308        }
 309
 310        public override double? GetRefreshProgress()
 311        {
 0312            return ProviderManager.GetRefreshProgress(Id);
 313        }
 314
 315        public Task ValidateChildren(IProgress<double> progress, CancellationToken cancellationToken)
 316        {
 0317            return ValidateChildren(progress, new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellation
 318        }
 319
 320        /// <summary>
 321        /// Validates that the children of the folder still exist.
 322        /// </summary>
 323        /// <param name="progress">The progress.</param>
 324        /// <param name="metadataRefreshOptions">The metadata refresh options.</param>
 325        /// <param name="recursive">if set to <c>true</c> [recursive].</param>
 326        /// <param name="allowRemoveRoot">remove item even this folder is root.</param>
 327        /// <param name="cancellationToken">The cancellation token.</param>
 328        /// <returns>Task.</returns>
 329        public Task ValidateChildren(IProgress<double> progress, MetadataRefreshOptions metadataRefreshOptions, bool rec
 330        {
 86331            Children = null; // invalidate cached children.
 86332            return ValidateChildrenInternal(progress, recursive, true, allowRemoveRoot, metadataRefreshOptions, metadata
 333        }
 334
 335        private Dictionary<Guid, BaseItem> GetActualChildrenDictionary()
 336        {
 77337            var dictionary = new Dictionary<Guid, BaseItem>();
 338
 77339            Children = null; // invalidate cached children.
 77340            var childrenList = GetChildrenForValidation();
 341
 288342            foreach (var child in childrenList)
 343            {
 67344                var id = child.Id;
 67345                if (dictionary.ContainsKey(id))
 346                {
 0347                    Logger.LogError(
 0348                        "Found folder containing items with duplicate id. Path: {Path}, Child Name: {ChildName}",
 0349                        Path ?? Name,
 0350                        child.Path ?? child.Name);
 351                }
 352                else
 353                {
 67354                    dictionary[id] = child;
 355                }
 356            }
 357
 77358            return dictionary;
 359        }
 360
 361        /// <summary>
 362        /// Validates the children internal.
 363        /// </summary>
 364        /// <param name="progress">The progress.</param>
 365        /// <param name="recursive">if set to <c>true</c> [recursive].</param>
 366        /// <param name="refreshChildMetadata">if set to <c>true</c> [refresh child metadata].</param>
 367        /// <param name="allowRemoveRoot">remove item even this folder is root.</param>
 368        /// <param name="refreshOptions">The refresh options.</param>
 369        /// <param name="directoryService">The directory service.</param>
 370        /// <param name="cancellationToken">The cancellation token.</param>
 371        /// <returns>Task.</returns>
 372        protected virtual async Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshCh
 373        {
 81374            if (recursive)
 375            {
 20376                ProviderManager.OnRefreshStart(this);
 377            }
 378
 379            try
 380            {
 381                if (GetParents().Any(f => f.Id.Equals(Id)))
 382                {
 0383                    throw new InvalidOperationException("Recursive datastructure detected abort processing this item.");
 384                }
 385
 81386                await ValidateChildrenInternal2(progress, recursive, refreshChildMetadata, allowRemoveRoot, refreshOptio
 81387            }
 388            finally
 389            {
 81390                if (recursive)
 391                {
 20392                    ProviderManager.OnRefreshComplete(this);
 393                }
 394
 395                // The subtree is done with, so stop holding it.
 81396                ReleaseCachedChildren();
 397            }
 81398        }
 399
 400        private static bool IsLibraryFolderAccessible(IDirectoryService directoryService, BaseItem item, bool checkColle
 401        {
 149402            if (!checkCollection && (item is BoxSet || string.Equals(item.FileNameWithoutExtension, "collections", Strin
 403            {
 0404                return true;
 405            }
 406
 407            // For top parents i.e. Library folders, skip the validation if it's empty or inaccessible
 149408            if (item.IsTopParent && !directoryService.IsAccessible(item.ContainingFolderPath))
 409            {
 49410                Logger.LogWarning("Library folder {LibraryFolderPath} is inaccessible or empty, skipping", item.Containi
 49411                return false;
 412            }
 413
 100414            return true;
 415        }
 416
 417        private async Task ValidateChildrenInternal2(IProgress<double> progress, bool recursive, bool refreshChildMetada
 418        {
 81419            if (!IsLibraryFolderAccessible(directoryService, this, allowRemoveRoot))
 420            {
 0421                return;
 422            }
 423
 81424            cancellationToken.ThrowIfCancellationRequested();
 425
 81426            var validChildren = new List<BaseItem>();
 81427            var accessibleChildren = new List<BaseItem>();
 81428            var validChildrenNeedGeneration = false;
 429
 81430            if (IsFileProtocol)
 431            {
 432                IEnumerable<BaseItem> nonCachedChildren;
 433
 434                try
 435                {
 436                    // Finish enumeration before mutating the library. An I/O failure, including
 437                    // one partway through a lazy enumeration, must not look like removed files.
 81438                    nonCachedChildren = GetNonCachedChildren(directoryService).ToArray();
 77439                }
 2440                catch (IOException ex)
 441                {
 2442                    Logger.LogError(ex, "Error retrieving children from file system");
 2443                    return;
 444                }
 2445                catch (SecurityException ex)
 446                {
 2447                    Logger.LogError(ex, "Error retrieving children from file system");
 2448                    return;
 449                }
 0450                catch (Exception ex)
 451                {
 0452                    Logger.LogError(ex, "Error retrieving children");
 0453                    return;
 454                }
 455
 77456                progress.Report(ProgressHelpers.RetrievedChildren);
 457
 77458                if (recursive)
 459                {
 20460                    ProviderManager.OnRefreshProgress(this, ProgressHelpers.RetrievedChildren);
 461                }
 462
 463                // Build a dictionary of the current children we have now by Id so we can compare quickly and easily
 77464                var currentChildren = GetActualChildrenDictionary();
 465
 466                // Create a list for our validated children
 77467                var newItems = new List<BaseItem>();
 77468                var actuallyRemoved = new List<BaseItem>();
 469
 470                // Build a reverse path→item lookup for detecting type changes
 77471                var currentChildrenByPath = new Dictionary<string, BaseItem>(StringComparer.OrdinalIgnoreCase);
 288472                foreach (var kvp in currentChildren)
 473                {
 67474                    if (!string.IsNullOrEmpty(kvp.Value.Path))
 475                    {
 67476                        currentChildrenByPath.TryAdd(kvp.Value.Path, kvp.Value);
 477                    }
 478                }
 479
 77480                cancellationToken.ThrowIfCancellationRequested();
 481
 290482                foreach (var child in nonCachedChildren)
 483                {
 68484                    if (!IsLibraryFolderAccessible(directoryService, child, allowRemoveRoot))
 485                    {
 486                        // Preserve inaccessible items so they aren't treated as removed.
 49487                        if (currentChildren.TryGetValue(child.Id, out var childrenToKeep))
 488                        {
 49489                            validChildren.Add(childrenToKeep);
 490                        }
 491
 49492                        continue;
 493                    }
 494
 19495                    if (currentChildren.TryGetValue(child.Id, out BaseItem currentChild))
 496                    {
 13497                        validChildren.Add(currentChild);
 13498                        accessibleChildren.Add(currentChild);
 499
 13500                        if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None)
 501                        {
 0502                            await currentChild.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken)
 503                        }
 504                        else
 505                        {
 506                            // metadata is up-to-date; make sure DB has correct images dimensions and hash
 13507                            await LibraryManager.UpdateImagesAsync(currentChild).ConfigureAwait(false);
 508                        }
 509
 13510                        continue;
 511                    }
 512
 513                    // Check if an existing item occupies the same path with different type/ID
 6514                    if (!string.IsNullOrEmpty(child.Path)
 6515                        && currentChildrenByPath.TryGetValue(child.Path, out var staleItem)
 6516                        && !staleItem.Id.Equals(child.Id))
 517                    {
 0518                        Logger.LogInformation(
 0519                            "Item type changed at {Path}: {OldType} -> {NewType}, removing stale entry",
 0520                            child.Path,
 0521                            staleItem.GetType().Name,
 0522                            child.GetType().Name);
 523
 0524                        currentChildren.Remove(staleItem.Id);
 0525                        currentChildrenByPath.Remove(child.Path);
 0526                        staleItem.SetParent(null);
 0527                        LibraryManager.DeleteItem(staleItem, new DeleteOptions { DeleteFileLocation = false }, this, fal
 0528                        actuallyRemoved.Add(staleItem);
 529                    }
 530
 531                    // Brand new item - needs to be added
 6532                    child.SetParent(this);
 6533                    newItems.Add(child);
 6534                    validChildren.Add(child);
 6535                    accessibleChildren.Add(child);
 536                }
 537
 538                // That's all the new and changed ones - now see if any have been removed and need cleanup
 77539                var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
 540
 541                // If it's an AggregateFolder, don't remove
 542                // Collect replaced primaries for deferred deletion (after CreateItems)
 77543                var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>();
 544
 545                // Build a set of paths that are alternate versions of valid children
 546                // These items should not be deleted - they're managed by their primary video
 77547                var alternateVersionPaths = validChildren
 77548                    .OfType<Video>()
 77549                    .SelectMany(v => v.LocalAlternateVersions ?? [])
 77550                    .Where(p => !string.IsNullOrEmpty(p))
 77551                    .ToHashSet(StringComparer.OrdinalIgnoreCase);
 552
 77553                if (itemsRemoved.Count > 0)
 554                {
 20555                    foreach (var item in itemsRemoved)
 556                    {
 5557                        if (!item.CanDelete())
 558                        {
 5559                            Logger.LogDebug("Item marked as non-removable, skipping: {Path}", item.Path ?? item.Name);
 5560                            continue;
 561                        }
 562
 563                        // Skip items that are alternate versions of another video
 0564                        if (item is Video video)
 565                        {
 566                            // Check if path is in LocalAlternateVersions of any valid child
 0567                            if (!string.IsNullOrEmpty(item.Path) && alternateVersionPaths.Contains(item.Path))
 568                            {
 0569                                Logger.LogDebug("Item path matches an alternate version, skipping deletion: {Path}", ite
 0570                                continue;
 571                            }
 572                        }
 573
 574                        // Defer deletion if this primary video is being replaced by a new primary
 575                        // that takes over its alternates. Deleting now would trigger premature
 576                        // promotion inside DeleteItem and write stale paths to collection NFOs.
 0577                        if (item is Video primaryVideo
 0578                            && !primaryVideo.PrimaryVersionId.HasValue
 0579                            && primaryVideo.OwnerId.IsEmpty()
 0580                            && (primaryVideo.LocalAlternateVersions ?? []).Any(p => alternateVersionPaths.Contains(p)))
 581                        {
 0582                            var newPrimary = validChildren
 0583                                .OfType<Video>()
 0584                                .FirstOrDefault(v => (v.LocalAlternateVersions ?? [])
 0585                                    .Any(p => (primaryVideo.LocalAlternateVersions ?? [])
 0586                                        .Any(op => string.Equals(op, p, StringComparison.OrdinalIgnoreCase))));
 0587                            if (newPrimary is not null)
 588                            {
 0589                                Logger.LogDebug("Deferring deletion of replaced primary: {Path}", item.Path);
 0590                                replacedPrimaries.Add((primaryVideo, newPrimary));
 0591                                actuallyRemoved.Add(item);
 0592                                item.SetParent(null);
 0593                                continue;
 594                            }
 595                        }
 596
 0597                        if (item.IsFileProtocol)
 598                        {
 0599                            Logger.LogDebug("Removed item: {Path}", item.Path);
 600
 0601                            actuallyRemoved.Add(item);
 0602                            item.SetParent(null);
 0603                            LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, fals
 604                        }
 605                    }
 606                }
 607
 77608                if (newItems.Count > 0)
 609                {
 6610                    LibraryManager.CreateItems(newItems, this, cancellationToken);
 611                }
 612
 613                // Process deferred replaced-primary deletions now that new primaries exist in DB/cache.
 614                // This avoids the premature promotion that would occur if DeleteItem ran before CreateItems.
 154615                foreach (var (oldPrimary, newPrimary) in replacedPrimaries)
 616                {
 0617                    Logger.LogInformation(
 0618                        "Processing deferred deletion of replaced primary {OldName} ({OldId}), new primary {NewName} ({N
 0619                        oldPrimary.Name,
 0620                        oldPrimary.Id,
 0621                        newPrimary.Name,
 0622                        newPrimary.Id);
 623
 0624                    await PromoteToPrimaryVersionAsync(newPrimary, cancellationToken).ConfigureAwait(false);
 625
 626                    // Reroute collection/playlist references from old primary to new primary
 0627                    await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(
 628
 629                    // Transfer alternates from old primary to new primary
 0630                    var localAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary).ToHashSet();
 0631                    var allAlternateIds = localAlternateIds
 0632                        .Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id))
 0633                        .Distinct()
 0634                        .ToList();
 635
 0636                    foreach (var altId in allAlternateIds)
 637                    {
 0638                        if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id))
 639                        {
 0640                            altVideo.SetPrimaryVersionId(newPrimary.Id);
 0641                            altVideo.OwnerId = localAlternateIds.Contains(altVideo.Id) ? newPrimary.Id : Guid.Empty;
 0642                            await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).Confi
 643                        }
 644                    }
 645
 646                    // Clear alternate arrays so DeleteItem won't trigger promotion
 0647                    oldPrimary.LocalAlternateVersions = [];
 0648                    oldPrimary.LinkedAlternateVersions = [];
 649
 650                    // Safe to delete now — no promotion will happen
 0651                    LibraryManager.DeleteItem(oldPrimary, new DeleteOptions { DeleteFileLocation = false }, this, false)
 0652                }
 653
 654                // Demote old primaries that are now alternate versions of another primary.
 655                // This handles the case where a new file is added that becomes the new primary
 656                // (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion), and the case
 657                // where the file that takes over was already in the library and merely traded
 658                // places with this one — so the new primary is looked up among all valid children
 659                // rather than only the newly created ones.
 660                // Items in replacedPrimaries are excluded (already in actuallyRemoved).
 77661                var oldPrimariesToDemote = new List<(Video OldPrimary, Video NewPrimary)>();
 164662                foreach (var item in itemsRemoved.Except(actuallyRemoved))
 663                {
 5664                    if (item is Video video
 5665                        && video.OwnerId.IsEmpty()
 5666                        && !string.IsNullOrEmpty(item.Path)
 5667                        && alternateVersionPaths.Contains(item.Path))
 668                    {
 0669                        var newPrimary = validChildren
 0670                            .OfType<Video>()
 0671                            .FirstOrDefault(v => (v.LocalAlternateVersions ?? [])
 0672                                .Any(p => string.Equals(p, item.Path, StringComparison.OrdinalIgnoreCase)));
 0673                        if (newPrimary is not null)
 674                        {
 0675                            oldPrimariesToDemote.Add((video, newPrimary));
 676                        }
 677                    }
 678                }
 679
 154680                foreach (var (oldPrimary, newPrimary) in oldPrimariesToDemote)
 681                {
 0682                    Logger.LogInformation(
 0683                        "Demoting old primary {OldName} ({OldId}) to alternate of new primary {NewName} ({NewId})",
 0684                        oldPrimary.Name,
 0685                        oldPrimary.Id,
 0686                        newPrimary.Name,
 0687                        newPrimary.Id);
 688
 0689                    await PromoteToPrimaryVersionAsync(newPrimary, cancellationToken).ConfigureAwait(false);
 690
 691                    // First: update old primary's alternate items to point to new primary.
 692                    // Order matters — update alternates FIRST so they don't get orphan-deleted
 693                    // when old primary's arrays are cleared.
 0694                    var oldLocalAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary).ToHashSet();
 0695                    var oldAlternateIds = oldLocalAlternateIds
 0696                        .Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id))
 0697                        .Distinct()
 0698                        .ToList();
 699
 0700                    foreach (var altId in oldAlternateIds)
 701                    {
 0702                        if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id))
 703                        {
 0704                            altVideo.SetPrimaryVersionId(newPrimary.Id);
 705
 706                            // Only a version stored next to the new primary is owned by it; one that
 707                            // was merged in by hand keeps its own row and must stay unowned.
 0708                            altVideo.OwnerId = oldLocalAlternateIds.Contains(altVideo.Id) ? newPrimary.Id : Guid.Empty;
 0709                            await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).Confi
 710                        }
 711                    }
 712
 713                    // Then: demote old primary — clear its arrays and set it as alternate of new primary
 0714                    oldPrimary.LocalAlternateVersions = [];
 0715                    oldPrimary.LinkedAlternateVersions = [];
 0716                    oldPrimary.SetPrimaryVersionId(newPrimary.Id);
 0717                    oldPrimary.OwnerId = newPrimary.Id;
 0718                    await oldPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAw
 719
 720                    // Re-route playlist/collection references from old primary to new primary
 0721                    await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(
 0722                }
 723
 724                // After removing items, reattach any detached user data to remaining children
 725                // that share the same user data keys (eg. same episode replaced with a new file).
 77726                if (actuallyRemoved.Count > 0)
 727                {
 0728                    var removedKeys = actuallyRemoved.SelectMany(i => i.GetUserDataKeys()).ToHashSet();
 0729                    foreach (var child in validChildren)
 730                    {
 0731                        if (child.GetUserDataKeys().Any(removedKeys.Contains))
 732                        {
 0733                            await child.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
 734                        }
 735                    }
 0736                }
 77737            }
 738            else
 739            {
 0740                validChildrenNeedGeneration = true;
 741            }
 742
 77743            progress.Report(ProgressHelpers.UpdatedChildItems);
 744
 77745            if (recursive)
 746            {
 20747                ProviderManager.OnRefreshProgress(this, ProgressHelpers.UpdatedChildItems);
 748            }
 749
 77750            cancellationToken.ThrowIfCancellationRequested();
 751
 77752            if (recursive)
 753            {
 20754                var folder = this;
 20755                var innerProgress = new Progress<double>(innerPercent =>
 20756                {
 20757                    var percent = ProgressHelpers.GetProgress(ProgressHelpers.UpdatedChildItems, ProgressHelpers.Scanned
 20758
 20759                    progress.Report(percent);
 20760
 20761                    ProviderManager.OnRefreshProgress(folder, percent);
 20762                });
 763
 20764                if (validChildrenNeedGeneration)
 765                {
 0766                    validChildren = Children.ToList();
 0767                    validChildrenNeedGeneration = false;
 768                }
 769
 20770                await ValidateSubFolders(accessibleChildren.OfType<Folder>().ToList(), directoryService, innerProgress, 
 771            }
 772
 77773            if (refreshChildMetadata)
 774            {
 77775                progress.Report(ProgressHelpers.ScannedSubfolders);
 776
 77777                if (recursive)
 778                {
 20779                    ProviderManager.OnRefreshProgress(this, ProgressHelpers.ScannedSubfolders);
 780                }
 781
 77782                var container = this as IMetadataContainer;
 783
 77784                var folder = this;
 77785                var innerProgress = new Progress<double>(innerPercent =>
 77786                {
 77787                    var percent = ProgressHelpers.GetProgress(ProgressHelpers.ScannedSubfolders, ProgressHelpers.Refresh
 77788
 77789                    progress.Report(percent);
 77790
 77791                    if (recursive)
 77792                    {
 77793                        ProviderManager.OnRefreshProgress(folder, percent);
 77794                    }
 77795                });
 796
 77797                if (container is not null)
 798                {
 0799                    await RefreshAllMetadataForContainer(container, refreshOptions, innerProgress, cancellationToken).Co
 800                }
 801                else
 802                {
 77803                    if (validChildrenNeedGeneration)
 804                    {
 0805                        Children = null; // invalidate cached children.
 0806                        validChildren = Children.ToList();
 807                    }
 808
 77809                    await RefreshMetadataRecursive(accessibleChildren, refreshOptions, recursive, innerProgress, cancell
 810                }
 811            }
 81812        }
 813
 814        private async Task PromoteToPrimaryVersionAsync(Video newPrimary, CancellationToken cancellationToken)
 815        {
 0816            if (!newPrimary.PrimaryVersionId.HasValue && newPrimary.OwnerId.IsEmpty())
 817            {
 0818                return;
 819            }
 820
 0821            Logger.LogInformation(
 0822                "Promoting {Name} ({Id}) to the primary version of its group",
 0823                newPrimary.Name,
 0824                newPrimary.Id);
 825
 0826            newPrimary.SetPrimaryVersionId(null);
 0827            newPrimary.OwnerId = Guid.Empty;
 0828            await newPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(fals
 0829        }
 830
 831        private async Task RefreshMetadataRecursive(IList<BaseItem> children, MetadataRefreshOptions refreshOptions, boo
 832        {
 77833            await RunTasks(
 77834                (baseItem, innerProgress) => RefreshChildMetadata(baseItem, refreshOptions, recursive && baseItem.IsFold
 77835                children,
 77836                progress,
 77837                cancellationToken).ConfigureAwait(false);
 77838        }
 839
 840        private async Task RefreshAllMetadataForContainer(IMetadataContainer container, MetadataRefreshOptions refreshOp
 841        {
 0842            if (container is Series series)
 843            {
 0844                await series.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false);
 845            }
 846
 0847            await container.RefreshAllMetadata(refreshOptions, progress, cancellationToken).ConfigureAwait(false);
 0848        }
 849
 850        private async Task RefreshChildMetadata(BaseItem child, MetadataRefreshOptions refreshOptions, bool recursive, I
 851        {
 19852            if (child is IMetadataContainer container)
 853            {
 0854                await RefreshAllMetadataForContainer(container, refreshOptions, progress, cancellationToken).ConfigureAw
 855            }
 856            else
 857            {
 19858                if (refreshOptions.RefreshItem(child))
 859                {
 19860                    await child.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false);
 861                }
 862
 19863                if (recursive && child is Folder folder)
 864                {
 0865                    folder.Children = null; // invalidate cached children.
 866                    try
 867                    {
 0868                        await folder.RefreshMetadataRecursive(folder.Children.Except([this, child]).ToList(), refreshOpt
 0869                    }
 870                    finally
 871                    {
 0872                        folder.ReleaseCachedChildren();
 873                    }
 874                }
 19875            }
 19876        }
 877
 878        /// <summary>
 879        /// Refreshes the children.
 880        /// </summary>
 881        /// <param name="children">The children.</param>
 882        /// <param name="directoryService">The directory service.</param>
 883        /// <param name="progress">The progress.</param>
 884        /// <param name="cancellationToken">The cancellation token.</param>
 885        /// <returns>Task.</returns>
 886        private async Task ValidateSubFolders(IList<Folder> children, IDirectoryService directoryService, IProgress<doub
 887        {
 20888            await RunTasks(
 20889                (folder, innerProgress) => folder.ValidateChildrenInternal(innerProgress, true, false, false, null, dire
 20890                children,
 20891                progress,
 20892                cancellationToken).ConfigureAwait(false);
 20893        }
 894
 895        /// <summary>
 896        /// Runs an action block on a list of children.
 897        /// </summary>
 898        /// <param name="task">The task to run for each child.</param>
 899        /// <param name="children">The list of children.</param>
 900        /// <param name="progress">The progress.</param>
 901        /// <param name="cancellationToken">The cancellation token.</param>
 902        /// <returns>Task.</returns>
 903        private async Task RunTasks<T>(Func<T, IProgress<double>, Task> task, IList<T> children, IProgress<double> progr
 904        {
 97905            await LimitedConcurrencyLibraryScheduler
 97906                .Enqueue(
 97907                    children.ToArray(),
 97908                    task,
 97909                    progress,
 97910                    cancellationToken)
 97911                .ConfigureAwait(false);
 97912        }
 913
 914        /// <summary>
 915        /// Get the children of this folder from the actual file system.
 916        /// </summary>
 917        /// <returns>IEnumerable{BaseItem}.</returns>
 918        /// <param name="directoryService">The directory service to use for operation.</param>
 919        /// <returns>Returns set of base items.</returns>
 920        protected virtual IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService)
 921        {
 77922            var collectionType = LibraryManager.GetContentType(this);
 77923            var libraryOptions = LibraryManager.GetLibraryOptions(this);
 924
 77925            return LibraryManager.ResolvePaths(GetFileSystemChildren(directoryService), directoryService, this, libraryO
 926        }
 927
 928        /// <summary>
 929        /// Get our children from the repo - stubbed for now.
 930        /// </summary>
 931        /// <returns>IEnumerable{BaseItem}.</returns>
 932        protected IReadOnlyList<BaseItem> GetCachedChildren()
 933        {
 187934            return ItemRepository.GetItemList(new InternalItemsQuery
 187935            {
 187936                Parent = this,
 187937                GroupByPresentationUniqueKey = false,
 187938                DtoOptions = new DtoOptions(true)
 187939            });
 940        }
 941
 942        private IReadOnlyList<BaseItem> GetChildrenForValidation()
 943        {
 77944            return ItemRepository.GetItemList(new InternalItemsQuery
 77945            {
 77946                Parent = this,
 77947                GroupByPresentationUniqueKey = false,
 77948                IncludeAlternateVersions = true,
 77949                DtoOptions = new DtoOptions(true)
 77950            });
 951        }
 952
 953        public virtual int GetChildCount(User user)
 954        {
 0955            if (LinkedChildren.Length > 0)
 956            {
 0957                if (this is not ICollectionFolder)
 958                {
 0959                    return GetChildren(user, true).Count;
 960                }
 961            }
 962
 0963            var result = GetItems(new InternalItemsQuery(user)
 0964            {
 0965                Recursive = false,
 0966                Limit = 0,
 0967                Parent = this,
 0968                DtoOptions = new DtoOptions(false)
 0969                {
 0970                    EnableImages = false
 0971                }
 0972            });
 973
 0974            return result.TotalRecordCount;
 975        }
 976
 977        public virtual int GetRecursiveChildCount(User user)
 978        {
 0979            return GetItems(new InternalItemsQuery(user)
 0980            {
 0981                Recursive = true,
 0982                IsFolder = false,
 0983                IsVirtualItem = false,
 0984                EnableTotalRecordCount = true,
 0985                Limit = 0,
 0986                DtoOptions = new DtoOptions(false)
 0987                {
 0988                    EnableImages = false
 0989                }
 0990            }).TotalRecordCount;
 991        }
 992
 993        public QueryResult<BaseItem> QueryRecursive(InternalItemsQuery query)
 994        {
 45995            if (!query.ForceDirect && CollapseBoxSetItems(query, this, query.User, ConfigurationManager))
 996            {
 0997                query.CollapseBoxSetItems = true;
 0998                SetCollapseBoxSetItemTypes(query);
 999            }
 1000
 451001            if (this is not UserRootFolder
 451002                && this is not AggregateFolder
 451003                && query.ParentId.IsEmpty())
 1004            {
 451005                query.Parent = this;
 1006            }
 1007
 1008            // BoxSets and Playlists can have per-user visibility (shares/open access) that is stored in the
 1009            // serialized item data and cannot be evaluated by the database query, so filter them in memory.
 451010            if (query.IncludeItemTypes.Length > 0
 451011                && query.IncludeItemTypes.All(t => t == BaseItemKind.BoxSet || t == BaseItemKind.Playlist))
 1012            {
 01013                return QueryWithPostFiltering(query);
 1014            }
 1015
 451016            return LibraryManager.GetItemsResult(query);
 1017        }
 1018
 1019        protected QueryResult<BaseItem> QueryWithPostFiltering(InternalItemsQuery query)
 1020        {
 01021            var startIndex = query.StartIndex;
 01022            var limit = query.Limit;
 1023
 01024            query.StartIndex = null;
 01025            query.Limit = null;
 1026
 01027            IEnumerable<BaseItem> itemsList = LibraryManager.GetItemList(query);
 01028            var user = query.User;
 1029
 01030            if (user is not null)
 1031            {
 1032                // needed for boxsets and playlists
 01033                itemsList = itemsList.Where(i => i.IsVisibleStandalone(query.User));
 1034            }
 1035
 1036            IEnumerable<BaseItem> returnItems;
 01037            int totalCount = 0;
 1038
 01039            if (query.EnableTotalRecordCount)
 1040            {
 01041                var itemArray = itemsList.ToArray();
 01042                totalCount = itemArray.Length;
 01043                returnItems = itemArray;
 1044            }
 1045            else
 1046            {
 01047                returnItems = itemsList;
 1048            }
 1049
 01050            if (limit.HasValue)
 1051            {
 01052                returnItems = returnItems.Skip(startIndex ?? 0).Take(limit.Value);
 1053            }
 01054            else if (startIndex.HasValue)
 1055            {
 01056                returnItems = returnItems.Skip(startIndex.Value);
 1057            }
 1058
 01059            return new QueryResult<BaseItem>(
 01060                query.StartIndex,
 01061                totalCount,
 01062                returnItems.ToArray());
 1063        }
 1064
 1065        private static BaseItem[] SortItemsByRequest(InternalItemsQuery query, IReadOnlyList<BaseItem> items)
 1066        {
 01067            return items.OrderBy(i => Array.IndexOf(query.ItemIds, i.Id)).ToArray();
 1068        }
 1069
 1070        public QueryResult<BaseItem> GetItems(InternalItemsQuery query)
 1071        {
 01072            if (query.ItemIds.Length > 0)
 1073            {
 01074                var result = LibraryManager.GetItemsResult(query);
 1075
 01076                if (query.OrderBy.Count == 0 && query.ItemIds.Length > 1)
 1077                {
 01078                    result.Items = SortItemsByRequest(query, result.Items);
 1079                }
 1080
 01081                return result;
 1082            }
 1083
 01084            return GetItemsInternal(query);
 1085        }
 1086
 1087        public IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery query)
 1088        {
 481089            query.EnableTotalRecordCount = false;
 1090
 481091            if (query.ItemIds.Length > 0)
 1092            {
 01093                var result = LibraryManager.GetItemList(query);
 1094
 01095                if (query.OrderBy.Count == 0 && query.ItemIds.Length > 1)
 1096                {
 01097                    return SortItemsByRequest(query, result);
 1098                }
 1099
 01100                return result;
 1101            }
 1102
 481103            return GetItemsInternal(query).Items;
 1104        }
 1105
 1106        protected virtual QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query)
 1107        {
 431108            if (SourceType == SourceType.Channel)
 1109            {
 1110                try
 1111                {
 01112                    query.Parent = this;
 01113                    query.ChannelIds = new[] { ChannelId };
 1114
 1115                    // Don't blow up here because it could cause parent screens with other content to fail
 01116                    return ChannelManager.GetChannelItemsInternal(query, new Progress<double>(), CancellationToken.None)
 1117                }
 01118                catch
 1119                {
 1120                    // Already logged at lower levels
 01121                    return new QueryResult<BaseItem>();
 1122                }
 1123            }
 1124
 431125            if (query.Recursive)
 1126            {
 431127                return QueryRecursive(query);
 1128            }
 1129
 01130            var user = query.User;
 1131
 1132            IEnumerable<BaseItem> items;
 1133
 01134            int totalItemCount = 0;
 01135            if (query.User is null)
 1136            {
 01137                items = UserViewBuilder.Filter(Children, user, query, UserDataManager, LibraryManager);
 01138                totalItemCount = items.Count();
 1139            }
 1140            else
 1141            {
 1142                // need to pass this param to the children.
 1143                // Note: Don't pass Limit/StartIndex here as pagination should happen after sorting in PostFilterAndSort
 01144                var childQuery = new InternalItemsQuery
 01145                {
 01146                    DisplayAlbumFolders = query.DisplayAlbumFolders,
 01147                    NameStartsWith = query.NameStartsWith,
 01148                    NameStartsWithOrGreater = query.NameStartsWithOrGreater,
 01149                    NameLessThan = query.NameLessThan
 01150                };
 1151
 01152                items = UserViewBuilder.Filter(
 01153                    GetChildren(user, true, out totalItemCount, childQuery),
 01154                    user,
 01155                    query,
 01156                    UserDataManager,
 01157                    LibraryManager);
 1158            }
 1159
 01160            return PostFilterAndSort(items, query);
 01161        }
 1162
 1163        protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query)
 1164        {
 01165            var user = query.User;
 1166
 1167            // Check recursive - don't substitute in plain folder views
 01168            if (user is not null)
 1169            {
 01170                items = CollapseBoxSetItemsIfNeeded(items, query, this, user, ConfigurationManager, CollectionManager);
 1171
 1172                // After collapse, BoxSets may have replaced items whose names matched the filter
 1173                // but the BoxSet's own name may not match. Re-apply name filtering so BoxSets
 1174                // appear under the correct letter (e.g. "Jump Street" under J, not under #).
 01175                items = ApplyNameFilter(items, query);
 1176            }
 1177
 01178            return UserViewBuilder.SortAndPage(items, null, query, LibraryManager);
 1179        }
 1180
 1181        private static IEnumerable<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query)
 1182        {
 01183            if (!string.IsNullOrWhiteSpace(query.NameStartsWith))
 1184            {
 01185                items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.OrdinalIgnoreCase)
 1186            }
 1187
 01188            if (!string.IsNullOrWhiteSpace(query.NameStartsWithOrGreater))
 1189            {
 01190                items = items.Where(i => string.Compare(i.SortName, query.NameStartsWithOrGreater, StringComparison.Ordi
 1191            }
 1192
 01193            if (!string.IsNullOrWhiteSpace(query.NameLessThan))
 1194            {
 01195                items = items.Where(i => string.Compare(i.SortName, query.NameLessThan, StringComparison.OrdinalIgnoreCa
 1196            }
 1197
 01198            return items;
 1199        }
 1200
 1201        private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded(
 1202            IEnumerable<BaseItem> items,
 1203            InternalItemsQuery query,
 1204            BaseItem queryParent,
 1205            User user,
 1206            IServerConfigurationManager configurationManager,
 1207            ICollectionManager collectionManager)
 1208        {
 01209            ArgumentNullException.ThrowIfNull(items);
 1210
 01211            if (!CollapseBoxSetItems(query, queryParent, user, configurationManager))
 1212            {
 01213                return items;
 1214            }
 1215
 01216            var config = configurationManager.Configuration;
 1217
 01218            bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
 01219            bool collapseSeries = config.EnableGroupingShowsIntoCollections;
 1220
 01221            if (user is null || (collapseMovies && collapseSeries))
 1222            {
 01223                return collectionManager.CollapseItemsWithinBoxSets(items, user);
 1224            }
 1225
 01226            if (!collapseMovies && !collapseSeries)
 1227            {
 01228                return items;
 1229            }
 1230
 01231            var collapsibleItems = new List<BaseItem>();
 01232            var remainingItems = new List<BaseItem>();
 1233
 01234            foreach (var item in items)
 1235            {
 01236                if ((collapseMovies && item is Movie) || (collapseSeries && item is Series))
 1237                {
 01238                    collapsibleItems.Add(item);
 1239                }
 1240                else
 1241                {
 01242                    remainingItems.Add(item);
 1243                }
 1244            }
 1245
 01246            if (collapsibleItems.Count == 0)
 1247            {
 01248                return remainingItems;
 1249            }
 1250
 01251            var collapsedItems = collectionManager.CollapseItemsWithinBoxSets(collapsibleItems, user);
 1252
 01253            return collapsedItems.Concat(remainingItems);
 1254        }
 1255
 1256        private static bool CollapseBoxSetItems(
 1257            InternalItemsQuery query,
 1258            BaseItem queryParent,
 1259            User user,
 1260            IServerConfigurationManager configurationManager)
 1261        {
 1262            // Could end up stuck in a loop like this
 451263            if (queryParent is BoxSet)
 1264            {
 01265                return false;
 1266            }
 1267
 451268            if (queryParent is Season)
 1269            {
 01270                return false;
 1271            }
 1272
 451273            if (queryParent is MusicAlbum)
 1274            {
 01275                return false;
 1276            }
 1277
 451278            if (queryParent is MusicArtist)
 1279            {
 01280                return false;
 1281            }
 1282
 451283            var param = query.CollapseBoxSetItems;
 451284            if (param.HasValue)
 1285            {
 451286                return param.Value && AllowBoxSetCollapsing(query);
 1287            }
 1288
 01289            var config = configurationManager.Configuration;
 1290
 01291            bool queryHasMovies = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Mov
 01292            bool queryHasSeries = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Ser
 1293
 01294            bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
 01295            bool collapseSeries = config.EnableGroupingShowsIntoCollections;
 1296
 01297            if (user is not null)
 1298            {
 01299                bool canCollapse = (queryHasMovies && collapseMovies) || (queryHasSeries && collapseSeries);
 01300                return canCollapse && AllowBoxSetCollapsing(query);
 1301            }
 1302
 01303            return (queryHasMovies || queryHasSeries) && AllowBoxSetCollapsing(query);
 1304        }
 1305
 1306        private void SetCollapseBoxSetItemTypes(InternalItemsQuery query)
 1307        {
 01308            var config = ConfigurationManager.Configuration;
 01309            bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
 01310            bool collapseSeries = config.EnableGroupingShowsIntoCollections;
 1311
 01312            if (collapseMovies && collapseSeries)
 1313            {
 1314                // Empty means collapse all types
 01315                query.CollapseBoxSetItemTypes = [];
 01316                return;
 1317            }
 1318
 01319            var types = new List<BaseItemKind>();
 01320            if (collapseMovies)
 1321            {
 01322                types.Add(BaseItemKind.Movie);
 1323            }
 1324
 01325            if (collapseSeries)
 1326            {
 01327                types.Add(BaseItemKind.Series);
 1328            }
 1329
 01330            query.CollapseBoxSetItemTypes = types.ToArray();
 01331        }
 1332
 1333        private static bool AllowBoxSetCollapsing(InternalItemsQuery request)
 1334        {
 01335            if (request.IsFavorite.HasValue)
 1336            {
 01337                return false;
 1338            }
 1339
 01340            if (request.IsFavoriteOrLiked.HasValue)
 1341            {
 01342                return false;
 1343            }
 1344
 01345            if (request.IsLiked.HasValue)
 1346            {
 01347                return false;
 1348            }
 1349
 01350            if (request.IsPlayed.HasValue)
 1351            {
 01352                return false;
 1353            }
 1354
 01355            if (request.IsResumable.HasValue)
 1356            {
 01357                return false;
 1358            }
 1359
 01360            if (request.IsFolder.HasValue)
 1361            {
 01362                return false;
 1363            }
 1364
 01365            if (request.Genres.Count > 0)
 1366            {
 01367                return false;
 1368            }
 1369
 01370            if (request.GenreIds.Count > 0)
 1371            {
 01372                return false;
 1373            }
 1374
 01375            if (request.HasImdbId.HasValue)
 1376            {
 01377                return false;
 1378            }
 1379
 01380            if (request.HasOfficialRating.HasValue)
 1381            {
 01382                return false;
 1383            }
 1384
 01385            if (request.HasOverview.HasValue)
 1386            {
 01387                return false;
 1388            }
 1389
 01390            if (request.HasParentalRating.HasValue)
 1391            {
 01392                return false;
 1393            }
 1394
 01395            if (request.HasSpecialFeature.HasValue)
 1396            {
 01397                return false;
 1398            }
 1399
 01400            if (request.HasSubtitles.HasValue)
 1401            {
 01402                return false;
 1403            }
 1404
 01405            if (request.HasThemeSong.HasValue)
 1406            {
 01407                return false;
 1408            }
 1409
 01410            if (request.HasThemeVideo.HasValue)
 1411            {
 01412                return false;
 1413            }
 1414
 01415            if (request.HasTmdbId.HasValue)
 1416            {
 01417                return false;
 1418            }
 1419
 01420            if (request.HasTrailer.HasValue)
 1421            {
 01422                return false;
 1423            }
 1424
 01425            if (request.ImageTypes.Length > 0)
 1426            {
 01427                return false;
 1428            }
 1429
 01430            if (request.Is3D.HasValue)
 1431            {
 01432                return false;
 1433            }
 1434
 01435            if (request.Is4K.HasValue)
 1436            {
 01437                return false;
 1438            }
 1439
 01440            if (request.IsHD.HasValue)
 1441            {
 01442                return false;
 1443            }
 1444
 01445            if (request.IsLocked.HasValue)
 1446            {
 01447                return false;
 1448            }
 1449
 01450            if (request.IsPlaceHolder.HasValue)
 1451            {
 01452                return false;
 1453            }
 1454
 01455            if (!string.IsNullOrWhiteSpace(request.Person))
 1456            {
 01457                return false;
 1458            }
 1459
 01460            if (request.PersonIds.Length > 0)
 1461            {
 01462                return false;
 1463            }
 1464
 01465            if (request.ItemIds.Length > 0)
 1466            {
 01467                return false;
 1468            }
 1469
 01470            if (request.StudioIds.Length > 0)
 1471            {
 01472                return false;
 1473            }
 1474
 01475            if (request.VideoTypes.Length > 0)
 1476            {
 01477                return false;
 1478            }
 1479
 01480            if (request.Years.Length > 0)
 1481            {
 01482                return false;
 1483            }
 1484
 01485            if (request.Tags.Length > 0)
 1486            {
 01487                return false;
 1488            }
 1489
 01490            if (request.OfficialRatings.Length > 0)
 1491            {
 01492                return false;
 1493            }
 1494
 01495            if (request.MinIndexNumber.HasValue)
 1496            {
 01497                return false;
 1498            }
 1499
 01500            if (request.OrderBy.Any(o =>
 01501                o.OrderBy == ItemSortBy.CommunityRating ||
 01502                o.OrderBy == ItemSortBy.CriticRating ||
 01503                o.OrderBy == ItemSortBy.Runtime))
 1504            {
 01505                return false;
 1506            }
 1507
 01508            return true;
 1509        }
 1510
 1511        public virtual IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, out int totalItemCount
 1512        {
 41513            ArgumentNullException.ThrowIfNull(user);
 41514            query ??= new InternalItemsQuery();
 41515            query.User = user;
 1516
 1517            // the true root should return our users root folder children
 41518            if (IsPhysicalRoot)
 1519            {
 01520                return LibraryManager.GetUserRootFolder().GetChildren(user, includeLinkedChildren, out totalItemCount);
 1521            }
 1522
 41523            var result = new Dictionary<Guid, BaseItem>();
 1524
 41525            totalItemCount = AddChildren(user, includeLinkedChildren, result, false, query);
 1526
 41527            return result.Values.ToArray();
 1528        }
 1529
 1530        public virtual IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery que
 1531        {
 41532            return GetChildren(user, includeLinkedChildren, out _, query);
 1533        }
 1534
 1535        protected virtual IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user)
 1536        {
 41537            return Children;
 1538        }
 1539
 1540        /// <summary>
 1541        /// Adds the children to list.
 1542        /// </summary>
 1543        private int AddChildren(User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive
 1544        {
 1545            // Prevent infinite recursion of nested folders
 41546            visitedFolders ??= new HashSet<Folder>();
 41547            if (!visitedFolders.Add(this))
 1548            {
 01549                return 0;
 1550            }
 1551
 1552            // If Query.AlbumFolders is set, then enforce the format as per the db in that it permits sub-folders in mus
 41553            IEnumerable<BaseItem> children = null;
 41554            if ((query?.DisplayAlbumFolders ?? false) && (this is MusicAlbum))
 1555            {
 01556                children = Children;
 01557                query = null;
 1558            }
 1559
 1560            // If there are not sub-folders, proceed as normal.
 41561            if (children is null)
 1562            {
 41563                children = GetEligibleChildrenForRecursiveChildren(user);
 1564            }
 1565
 41566            if (includeLinkedChildren)
 1567            {
 41568                children = children.Concat(GetLinkedChildren(user)).ToArray();
 1569            }
 1570
 41571            return AddChildrenFromCollection(children, user, includeLinkedChildren, result, recursive, query, visitedFol
 1572        }
 1573
 1574        private int AddChildrenFromCollection(IEnumerable<BaseItem> children, User user, bool includeLinkedChildren, Dic
 1575        {
 41576            query ??= new InternalItemsQuery();
 41577            var limit = query.Limit > 0 ? query.Limit : int.MaxValue;
 41578            query.Limit = 0;
 1579
 41580            var visibleChildren = children
 41581                .Where(e => e.IsVisible(user))
 41582                .ToArray();
 1583
 41584            var realChildren = UserViewBuilder.Filter(visibleChildren, query.User, query, UserDataManager, LibraryManage
 41585                .ToArray();
 1586
 41587            var childCount = realChildren.Length;
 41588            if (result.Count < limit)
 1589            {
 41590                var remainingCount = (int)(limit - result.Count);
 161591                foreach (var child in realChildren
 41592                    .Skip(query.StartIndex ?? 0)
 41593                    .Take(remainingCount))
 1594                {
 41595                    result[child.Id] = child;
 1596                }
 1597            }
 1598
 41599            if (recursive)
 1600            {
 01601                foreach (var child in visibleChildren
 01602                    .Where(e => e.IsFolder)
 01603                    .OfType<Folder>())
 1604                {
 01605                    childCount += child.AddChildren(user, includeLinkedChildren, result, true, query, visitedFolders);
 1606                }
 1607            }
 1608
 41609            return childCount;
 1610        }
 1611
 1612        public virtual IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCo
 1613        {
 01614            ArgumentNullException.ThrowIfNull(user);
 1615
 01616            var result = new Dictionary<Guid, BaseItem>();
 1617
 01618            totalCount = AddChildren(user, true, result, true, query);
 1619
 01620            return result.Values.ToArray();
 1621        }
 1622
 1623        /// <summary>
 1624        /// Gets the recursive children.
 1625        /// </summary>
 1626        /// <returns>IList{BaseItem}.</returns>
 1627        public IReadOnlyList<BaseItem> GetRecursiveChildren()
 1628        {
 01629            return GetRecursiveChildren(true);
 1630        }
 1631
 1632        public IReadOnlyList<BaseItem> GetRecursiveChildren(bool includeLinkedChildren)
 1633        {
 01634            return GetRecursiveChildren(i => true, includeLinkedChildren);
 1635        }
 1636
 1637        public IReadOnlyList<BaseItem> GetRecursiveChildren(Func<BaseItem, bool> filter)
 1638        {
 01639            return GetRecursiveChildren(filter, true);
 1640        }
 1641
 1642        public IReadOnlyList<BaseItem> GetRecursiveChildren(Func<BaseItem, bool> filter, bool includeLinkedChildren)
 1643        {
 01644            var result = new Dictionary<Guid, BaseItem>();
 1645
 01646            AddChildrenToList(result, includeLinkedChildren, true, filter);
 1647
 01648            return result.Values.ToArray();
 1649        }
 1650
 1651        /// <summary>
 1652        /// Adds the children to list.
 1653        /// </summary>
 1654        private void AddChildrenToList(Dictionary<Guid, BaseItem> result, bool includeLinkedChildren, bool recursive, Fu
 1655        {
 01656            foreach (var child in Children)
 1657            {
 01658                if (filter is null || filter(child))
 1659                {
 01660                    result[child.Id] = child;
 1661                }
 1662
 01663                if (recursive && child.IsFolder)
 1664                {
 01665                    var folder = (Folder)child;
 1666
 1667                    // We can only support includeLinkedChildren for the first folder, or we might end up stuck in a loo
 01668                    folder.AddChildrenToList(result, false, true, filter);
 1669                }
 1670            }
 1671
 01672            if (includeLinkedChildren)
 1673            {
 01674                foreach (var child in GetLinkedChildren())
 1675                {
 01676                    if (filter is null || filter(child))
 1677                    {
 01678                        result[child.Id] = child;
 1679                    }
 1680                }
 1681            }
 01682        }
 1683
 1684        /// <summary>
 1685        /// Gets the linked children.
 1686        /// </summary>
 1687        /// <returns>IEnumerable{BaseItem}.</returns>
 1688        public List<BaseItem> GetLinkedChildren()
 1689        {
 61690            return GetLinkedChildren(new DtoOptions());
 1691        }
 1692
 1693        /// <summary>
 1694        /// Gets the linked children, populating only what <paramref name="options"/> asks for.
 1695        /// </summary>
 1696        /// <param name="options">Fields to populate on the resolved children.</param>
 1697        /// <returns>The resolved children.</returns>
 1698        public List<BaseItem> GetLinkedChildren(DtoOptions options)
 1699        {
 61700            var resolved = ResolveLinkedChildren(LinkedChildren, options);
 61701            var list = new List<BaseItem>(resolved.Count);
 181702            foreach (var (_, item) in resolved)
 1703            {
 31704                list.Add(item);
 1705            }
 1706
 61707            return list;
 1708        }
 1709
 1710        public bool ContainsLinkedChildByItemId(Guid itemId)
 1711        {
 01712            var linkedChildren = LinkedChildren;
 01713            foreach (var i in linkedChildren)
 1714            {
 01715                if (i.ItemId.HasValue)
 1716                {
 01717                    if (i.ItemId.Value.Equals(itemId))
 1718                    {
 01719                        return true;
 1720                    }
 1721
 1722                    continue;
 1723                }
 1724
 01725                var child = GetLinkedChild(i);
 1726
 01727                if (child is not null && child.Id.Equals(itemId))
 1728                {
 01729                    return true;
 1730                }
 1731            }
 1732
 01733            return false;
 1734        }
 1735
 1736        public List<BaseItem> GetLinkedChildren(User user)
 1737        {
 41738            if (!FilterLinkedChildrenPerUser || user is null)
 1739            {
 41740                return GetLinkedChildren();
 1741            }
 1742
 01743            var linkedChildren = LinkedChildren;
 01744            var list = new List<BaseItem>(linkedChildren.Length);
 1745
 01746            if (linkedChildren.Length == 0)
 1747            {
 01748                return list;
 1749            }
 1750
 01751            var allUserRootChildren = LibraryManager.GetUserRootFolder()
 01752                .GetChildren(user, true)
 01753                .OfType<Folder>()
 01754                .ToList();
 1755
 01756            var collectionFolderIds = allUserRootChildren
 01757                .Select(i => i.Id)
 01758                .ToList();
 1759
 01760            foreach (var i in linkedChildren)
 1761            {
 01762                var child = GetLinkedChild(i);
 1763
 01764                if (child is null)
 1765                {
 1766                    continue;
 1767                }
 1768
 01769                var childOwner = child.GetOwner() ?? child;
 1770
 01771                if (child is not IItemByName)
 1772                {
 01773                    var childProtocol = childOwner.PathProtocol;
 01774                    if (!childProtocol.HasValue || childProtocol.Value != Model.MediaInfo.MediaProtocol.File)
 1775                    {
 01776                        if (!childOwner.IsVisibleStandalone(user))
 1777                        {
 01778                            continue;
 1779                        }
 1780                    }
 1781                    else
 1782                    {
 01783                        var itemCollectionFolderIds =
 01784                            LibraryManager.GetCollectionFolders(childOwner, allUserRootChildren).Select(f => f.Id);
 1785
 01786                        if (!itemCollectionFolderIds.Any(collectionFolderIds.Contains))
 1787                        {
 1788                            continue;
 1789                        }
 1790                    }
 1791                }
 1792
 01793                list.Add(child);
 1794            }
 1795
 01796            return list;
 1797        }
 1798
 1799        /// <summary>
 1800        /// Gets the linked children.
 1801        /// </summary>
 1802        /// <returns>IEnumerable{BaseItem}.</returns>
 1803        public IReadOnlyList<Tuple<LinkedChild, BaseItem>> GetLinkedChildrenInfos()
 1804        {
 01805            return ResolveLinkedChildren(LinkedChildren)
 01806                .Select(t => new Tuple<LinkedChild, BaseItem>(t.Info, t.Item))
 01807                .ToArray();
 1808        }
 1809
 1810        /// <summary>
 1811        /// Resolves a list of <see cref="LinkedChild"/> entries to their <see cref="BaseItem"/> targets,
 1812        /// batching the database lookup across all entries with a known ItemId.
 1813        /// Entries without a usable ItemId fall back to the per-entry <see cref="BaseItem.GetLinkedChild"/>
 1814        /// path (legacy path-based resolution).
 1815        /// </summary>
 1816        /// <param name="linkedChildren">Linked children to resolve.</param>
 1817        /// <param name="options">Fields to populate on the resolved items; all fields when null.</param>
 1818        /// <returns>Each input entry paired with its resolved item; entries that fail to resolve are dropped.</returns>
 1819        private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList<LinkedChild> linkedChildren,
 1820        {
 61821            var resolved = new List<(LinkedChild Info, BaseItem Item)>(linkedChildren.Count);
 61822            if (linkedChildren.Count == 0)
 1823            {
 41824                return resolved;
 1825            }
 1826
 21827            var idsToBatch = new HashSet<Guid>();
 101828            foreach (var info in linkedChildren)
 1829            {
 31830                if (info.ItemId.HasValue && !info.ItemId.Value.IsEmpty())
 1831                {
 31832                    idsToBatch.Add(info.ItemId.Value);
 1833                }
 1834            }
 1835
 21836            Dictionary<Guid, BaseItem> byId = null;
 21837            if (idsToBatch.Count > 0)
 1838            {
 21839                var batched = LibraryManager.GetItemList(new InternalItemsQuery
 21840                {
 21841                    ItemIds = [.. idsToBatch],
 21842                    DtoOptions = options ?? new DtoOptions()
 21843                });
 21844                byId = new Dictionary<Guid, BaseItem>(batched.Count);
 101845                foreach (var item in batched)
 1846                {
 31847                    byId[item.Id] = item;
 1848                }
 1849            }
 1850
 101851            foreach (var info in linkedChildren)
 1852            {
 31853                BaseItem item = null;
 31854                if (byId is not null && info.ItemId.HasValue && byId.TryGetValue(info.ItemId.Value, out var batchedItem)
 1855                {
 31856                    item = batchedItem;
 1857                }
 1858                else
 1859                {
 1860                    // ItemId is missing/empty or the batched query couldn't return the item
 1861                    // (e.g. it has been removed). Fall back to per-entry resolution, which also
 1862                    // handles legacy path-based linked children.
 01863                    item = GetLinkedChild(info);
 1864                }
 1865
 31866                if (item is not null)
 1867                {
 31868                    resolved.Add((info, item));
 1869                }
 1870            }
 1871
 21872            return resolved;
 1873        }
 1874
 1875        protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystem
 1876        {
 721877            var changesFound = false;
 1878
 721879            if (IsFileProtocol)
 1880            {
 721881                if (RefreshLinkedChildren(fileSystemChildren))
 1882                {
 01883                    changesFound = true;
 1884                }
 1885            }
 1886
 721887            var baseHasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).Configur
 1888
 721889            return baseHasChanges || changesFound;
 721890        }
 1891
 1892        /// <summary>
 1893        /// Refreshes the linked children.
 1894        /// </summary>
 1895        /// <param name="fileSystemChildren">The enumerable of file system metadata.</param>
 1896        /// <returns><c>true</c> if the linked children were updated, <c>false</c> otherwise.</returns>
 1897        protected virtual bool RefreshLinkedChildren(IEnumerable<FileSystemMetadata> fileSystemChildren)
 1898        {
 291899            if (SupportsShortcutChildren)
 1900            {
 291901                var newShortcutLinks = fileSystemChildren
 291902                    .Where(i => !i.IsDirectory && FileSystem.IsShortcut(i.FullName))
 291903                    .Select(i =>
 291904                    {
 291905                        try
 291906                        {
 291907                            Logger.LogDebug("Found shortcut at {0}", i.FullName);
 291908
 291909                            var resolvedPath = CollectionFolder.ApplicationHost.ExpandVirtualPath(FileSystem.ResolveShor
 291910
 291911                            if (!string.IsNullOrEmpty(resolvedPath))
 291912                            {
 291913#pragma warning disable CS0618 // Type or member is obsolete - shortcuts require Path for lazy ItemId resolution
 291914                                return new LinkedChild
 291915                                {
 291916                                    Path = resolvedPath,
 291917                                    Type = LinkedChildType.Shortcut
 291918                                };
 291919#pragma warning restore CS0618
 291920                            }
 291921
 291922                            Logger.LogError("Error resolving shortcut {0}", i.FullName);
 291923
 291924                            return null;
 291925                        }
 291926                        catch (IOException ex)
 291927                        {
 291928                            Logger.LogError(ex, "Error resolving shortcut {0}", i.FullName);
 291929                            return null;
 291930                        }
 291931                    })
 291932                    .Where(i => i is not null)
 291933                    .ToList();
 1934
 291935                var currentShortcutLinks = LinkedChildren.Where(i => i.Type == LinkedChildType.Shortcut).ToList();
 1936
 291937                if (!newShortcutLinks.SequenceEqual(currentShortcutLinks, new LinkedChildComparer(FileSystem)))
 1938                {
 01939                    Logger.LogInformation("Shortcut links have changed for {0}", Path);
 1940
 01941                    newShortcutLinks.AddRange(LinkedChildren.Where(i => i.Type == LinkedChildType.Manual));
 01942                    LinkedChildren = newShortcutLinks.ToArray();
 01943                    return true;
 1944                }
 1945            }
 1946
 291947            return false;
 1948        }
 1949
 1950        /// <summary>
 1951        /// Marks the played.
 1952        /// </summary>
 1953        /// <param name="user">The user.</param>
 1954        /// <param name="datePlayed">The date played.</param>
 1955        /// <param name="resetPosition">if set to <c>true</c> [reset position].</param>
 1956        public override void MarkPlayed(
 1957            User user,
 1958            DateTime? datePlayed,
 1959            bool resetPosition)
 1960        {
 01961            var query = new InternalItemsQuery
 01962            {
 01963                User = user,
 01964                Recursive = true,
 01965                IsFolder = false,
 01966                EnableTotalRecordCount = false
 01967            };
 1968
 01969            if (!user.DisplayMissingEpisodes)
 1970            {
 01971                query.IsVirtualItem = false;
 1972            }
 1973
 01974            var itemsResult = GetItemList(query);
 1975
 1976            // Sweep through recursively and update status
 01977            foreach (var item in itemsResult)
 1978            {
 01979                if (item.IsVirtualItem)
 1980                {
 1981                    // The querying doesn't support virtual unaired
 01982                    var episode = item as Episode;
 01983                    if (episode is not null && episode.IsUnaired)
 1984                    {
 1985                        continue;
 1986                    }
 1987                }
 1988
 01989                item.MarkPlayed(user, datePlayed, resetPosition);
 1990            }
 01991        }
 1992
 1993        /// <summary>
 1994        /// Marks the unplayed.
 1995        /// </summary>
 1996        /// <param name="user">The user.</param>
 1997        public override void MarkUnplayed(User user)
 1998        {
 01999            var itemsResult = GetItemList(new InternalItemsQuery
 02000            {
 02001                User = user,
 02002                Recursive = true,
 02003                IsFolder = false,
 02004                EnableTotalRecordCount = false
 02005            });
 2006
 2007            // Sweep through recursively and update status
 02008            foreach (var item in itemsResult)
 2009            {
 02010                item.MarkUnplayed(user);
 2011            }
 02012        }
 2013
 2014        public override bool IsPlayed(User user, UserItemData userItemData)
 2015        {
 02016            return ItemRepository.GetIsPlayed(user, Id, true);
 2017        }
 2018
 2019        public override bool IsUnplayed(User user, UserItemData userItemData)
 2020        {
 02021            return !IsPlayed(user, userItemData);
 2022        }
 2023
 2024        public override void FillUserDataDtoValues(
 2025            UserItemDataDto dto,
 2026            UserItemData userData,
 2027            BaseItemDto itemDto,
 2028            User user,
 2029            DtoOptions fields,
 2030            (int Played, int Total)? precomputedCounts = null)
 2031        {
 112032            if (!SupportsUserDataFromChildren)
 2033            {
 92034                return;
 2035            }
 2036
 22037            if (SupportsPlayedStatus || (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)))
 2038            {
 2039                int playedCount;
 2040                int totalCount;
 2041
 22042                if (precomputedCounts.HasValue)
 2043                {
 2044                    // Use batch-fetched counts (avoids N+1 queries)
 22045                    (playedCount, totalCount) = precomputedCounts.Value;
 2046                }
 2047                else
 2048                {
 2049                    // Fall back to per-item query when no batch data is available
 02050                    var query = new InternalItemsQuery(user);
 2051
 02052                    if (LinkedChildren.Length > 0)
 2053                    {
 02054                        (playedCount, totalCount) = ItemCountService.GetPlayedAndTotalCountFromLinkedChildren(query, Id)
 2055                    }
 2056                    else
 2057                    {
 02058                        (playedCount, totalCount) = ItemCountService.GetPlayedAndTotalCount(query, Id);
 2059                    }
 2060                }
 2061
 22062                if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount))
 2063                {
 22064                    itemDto.RecursiveItemCount = totalCount;
 2065                }
 2066
 22067                if (SupportsPlayedStatus)
 2068                {
 22069                    var unplayedCount = totalCount - playedCount;
 22070                    dto.UnplayedItemCount = unplayedCount;
 2071
 22072                    if (totalCount > 0)
 2073                    {
 12074                        dto.PlayedPercentage = playedCount / (double)totalCount * 100;
 12075                        dto.Played = playedCount >= totalCount;
 2076                    }
 2077                    else
 2078                    {
 12079                        dto.Played = true;
 2080                    }
 2081                }
 2082            }
 12083        }
 2084
 2085        /// <summary>
 2086        /// Contains constants used when reporting scan progress.
 2087        /// </summary>
 2088        private static class ProgressHelpers
 2089        {
 2090            /// <summary>
 2091            /// Reported after the folders immediate children are retrieved.
 2092            /// </summary>
 2093            public const int RetrievedChildren = 5;
 2094
 2095            /// <summary>
 2096            /// Reported after add, updating, or deleting child items from the LibraryManager.
 2097            /// </summary>
 2098            public const int UpdatedChildItems = 10;
 2099
 2100            /// <summary>
 2101            /// Reported once subfolders are scanned.
 2102            /// When scanning subfolders, the progress will be between [UpdatedItems, ScannedSubfolders].
 2103            /// </summary>
 2104            public const int ScannedSubfolders = 50;
 2105
 2106            /// <summary>
 2107            /// Reported once metadata is refreshed.
 2108            /// When refreshing metadata, the progress will be between [ScannedSubfolders, MetadataRefreshed].
 2109            /// </summary>
 2110            public const int RefreshedMetadata = 100;
 2111
 2112            /// <summary>
 2113            /// Gets the current progress given the previous step, next step, and progress in between.
 2114            /// </summary>
 2115            /// <param name="previousProgressStep">The previous progress step.</param>
 2116            /// <param name="nextProgressStep">The next progress step.</param>
 2117            /// <param name="currentProgress">The current progress step.</param>
 2118            /// <returns>The progress.</returns>
 2119            public static double GetProgress(int previousProgressStep, int nextProgressStep, double currentProgress)
 2120            {
 1042121                return previousProgressStep + ((nextProgressStep - previousProgressStep) * (currentProgress / 100));
 2122            }
 2123        }
 2124    }
 2125}

Methods/Properties

.ctor()
get_LinkedChildren()
set_LinkedChildren(MediaBrowser.Controller.Entities.LinkedChild[])
get_SupportsThemeMedia()
get_IsPreSorted()
get_IsPhysicalRoot()
get_SupportsInheritedParentImages()
get_SupportsPlayedStatus()
get_IsFolder()
get_IsDisplayedAsFolder()
get_SupportsCumulativeRunTimeTicks()
get_SupportsDateLastMediaAdded()
get_FileNameWithoutExtension()
get_Children()
set_Children(System.Collections.Generic.IEnumerable`1<MediaBrowser.Controller.Entities.BaseItem>)
get_RecursiveChildren()
get_SupportsShortcutChildren()
get_FilterLinkedChildrenPerUser()
get_SupportsOwnedItems()
get_SupportsUserDataFromChildren()
CanDelete()
RequiresRefresh()
AddChild(MediaBrowser.Controller.Entities.BaseItem)
IsVisible(Jellyfin.Database.Implementations.Entities.User,System.Boolean)
LoadChildren()
ReleaseCachedChildren()
GetRefreshProgress()
ValidateChildren(System.IProgress`1<System.Double>,System.Threading.CancellationToken)
ValidateChildren(System.IProgress`1<System.Double>,MediaBrowser.Controller.Providers.MetadataRefreshOptions,System.Boolean,System.Boolean,System.Threading.CancellationToken)
GetActualChildrenDictionary()
ValidateChildrenInternal()
IsLibraryFolderAccessible(MediaBrowser.Controller.Providers.IDirectoryService,MediaBrowser.Controller.Entities.BaseItem,System.Boolean)
ValidateChildrenInternal2()
PromoteToPrimaryVersionAsync()
RefreshMetadataRecursive()
RefreshAllMetadataForContainer()
RefreshChildMetadata()
ValidateSubFolders()
RunTasks()
GetNonCachedChildren(MediaBrowser.Controller.Providers.IDirectoryService)
GetCachedChildren()
GetChildrenForValidation()
GetChildCount(Jellyfin.Database.Implementations.Entities.User)
GetRecursiveChildCount(Jellyfin.Database.Implementations.Entities.User)
QueryRecursive(MediaBrowser.Controller.Entities.InternalItemsQuery)
QueryWithPostFiltering(MediaBrowser.Controller.Entities.InternalItemsQuery)
SortItemsByRequest(MediaBrowser.Controller.Entities.InternalItemsQuery,System.Collections.Generic.IReadOnlyList`1<MediaBrowser.Controller.Entities.BaseItem>)
GetItems(MediaBrowser.Controller.Entities.InternalItemsQuery)
GetItemList(MediaBrowser.Controller.Entities.InternalItemsQuery)
GetItemsInternal(MediaBrowser.Controller.Entities.InternalItemsQuery)
PostFilterAndSort(System.Collections.Generic.IEnumerable`1<MediaBrowser.Controller.Entities.BaseItem>,MediaBrowser.Controller.Entities.InternalItemsQuery)
ApplyNameFilter(System.Collections.Generic.IEnumerable`1<MediaBrowser.Controller.Entities.BaseItem>,MediaBrowser.Controller.Entities.InternalItemsQuery)
CollapseBoxSetItemsIfNeeded(System.Collections.Generic.IEnumerable`1<MediaBrowser.Controller.Entities.BaseItem>,MediaBrowser.Controller.Entities.InternalItemsQuery,MediaBrowser.Controller.Entities.BaseItem,Jellyfin.Database.Implementations.Entities.User,MediaBrowser.Controller.Configuration.IServerConfigurationManager,MediaBrowser.Controller.Collections.ICollectionManager)
CollapseBoxSetItems(MediaBrowser.Controller.Entities.InternalItemsQuery,MediaBrowser.Controller.Entities.BaseItem,Jellyfin.Database.Implementations.Entities.User,MediaBrowser.Controller.Configuration.IServerConfigurationManager)
SetCollapseBoxSetItemTypes(MediaBrowser.Controller.Entities.InternalItemsQuery)
AllowBoxSetCollapsing(MediaBrowser.Controller.Entities.InternalItemsQuery)
GetChildren(Jellyfin.Database.Implementations.Entities.User,System.Boolean,System.Int32&,MediaBrowser.Controller.Entities.InternalItemsQuery)
GetChildren(Jellyfin.Database.Implementations.Entities.User,System.Boolean,MediaBrowser.Controller.Entities.InternalItemsQuery)
GetEligibleChildrenForRecursiveChildren(Jellyfin.Database.Implementations.Entities.User)
AddChildren(Jellyfin.Database.Implementations.Entities.User,System.Boolean,System.Collections.Generic.Dictionary`2<System.Guid,MediaBrowser.Controller.Entities.BaseItem>,System.Boolean,MediaBrowser.Controller.Entities.InternalItemsQuery,System.Collections.Generic.HashSet`1<MediaBrowser.Controller.Entities.Folder>)
AddChildrenFromCollection(System.Collections.Generic.IEnumerable`1<MediaBrowser.Controller.Entities.BaseItem>,Jellyfin.Database.Implementations.Entities.User,System.Boolean,System.Collections.Generic.Dictionary`2<System.Guid,MediaBrowser.Controller.Entities.BaseItem>,System.Boolean,MediaBrowser.Controller.Entities.InternalItemsQuery,System.Collections.Generic.HashSet`1<MediaBrowser.Controller.Entities.Folder>)
GetRecursiveChildren(Jellyfin.Database.Implementations.Entities.User,MediaBrowser.Controller.Entities.InternalItemsQuery,System.Int32&)
GetRecursiveChildren()
GetRecursiveChildren(System.Boolean)
GetRecursiveChildren(System.Func`2<MediaBrowser.Controller.Entities.BaseItem,System.Boolean>)
GetRecursiveChildren(System.Func`2<MediaBrowser.Controller.Entities.BaseItem,System.Boolean>,System.Boolean)
AddChildrenToList(System.Collections.Generic.Dictionary`2<System.Guid,MediaBrowser.Controller.Entities.BaseItem>,System.Boolean,System.Boolean,System.Func`2<MediaBrowser.Controller.Entities.BaseItem,System.Boolean>)
GetLinkedChildren()
GetLinkedChildren(MediaBrowser.Controller.Dto.DtoOptions)
ContainsLinkedChildByItemId(System.Guid)
GetLinkedChildren(Jellyfin.Database.Implementations.Entities.User)
GetLinkedChildrenInfos()
ResolveLinkedChildren(System.Collections.Generic.IReadOnlyList`1<MediaBrowser.Controller.Entities.LinkedChild>,MediaBrowser.Controller.Dto.DtoOptions)
RefreshedOwnedItems()
RefreshLinkedChildren(System.Collections.Generic.IEnumerable`1<MediaBrowser.Model.IO.FileSystemMetadata>)
MarkPlayed(Jellyfin.Database.Implementations.Entities.User,System.Nullable`1<System.DateTime>,System.Boolean)
MarkUnplayed(Jellyfin.Database.Implementations.Entities.User)
IsPlayed(Jellyfin.Database.Implementations.Entities.User,MediaBrowser.Controller.Entities.UserItemData)
IsUnplayed(Jellyfin.Database.Implementations.Entities.User,MediaBrowser.Controller.Entities.UserItemData)
FillUserDataDtoValues(MediaBrowser.Model.Dto.UserItemDataDto,MediaBrowser.Controller.Entities.UserItemData,MediaBrowser.Model.Dto.BaseItemDto,Jellyfin.Database.Implementations.Entities.User,MediaBrowser.Controller.Dto.DtoOptions,System.Nullable`1<System.ValueTuple`2<System.Int32,System.Int32>>)
GetProgress(System.Int32,System.Int32,System.Double)