#nullable disable #pragma warning disable CA1002, CA1721, CA1819, CS1591 using System; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; using System.Security; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using J2N.Collections.Generic.Extensions; using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LibraryTaskScheduler; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; using Microsoft.Extensions.Logging; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; using Season = MediaBrowser.Controller.Entities.TV.Season; using Series = MediaBrowser.Controller.Entities.TV.Series; namespace MediaBrowser.Controller.Entities { /// /// Class Folder. /// public class Folder : BaseItem { private IEnumerable _children; public Folder() { LinkedChildren = Array.Empty(); } public static IUserViewManager UserViewManager { get; set; } public static ILimitedConcurrencyLibraryScheduler LimitedConcurrencyLibraryScheduler { get; set; } /// /// Gets or sets a value indicating whether this instance is root. /// /// true if this instance is root; otherwise, false. public bool IsRoot { get; set; } /// /// Gets or sets the linked children. /// [JsonIgnore] public LinkedChild[] LinkedChildren { get; set; } [JsonIgnore] public DateTime? DateLastMediaAdded { get; set; } [JsonIgnore] public override bool SupportsThemeMedia => true; [JsonIgnore] public virtual bool IsPreSorted => false; [JsonIgnore] public virtual bool IsPhysicalRoot => false; [JsonIgnore] public override bool SupportsInheritedParentImages => true; [JsonIgnore] public override bool SupportsPlayedStatus => true; /// /// Gets a value indicating whether this instance is folder. /// /// true if this instance is folder; otherwise, false. [JsonIgnore] public override bool IsFolder => true; [JsonIgnore] public override bool IsDisplayedAsFolder => true; [JsonIgnore] public virtual bool SupportsCumulativeRunTimeTicks => false; [JsonIgnore] public virtual bool SupportsDateLastMediaAdded => false; [JsonIgnore] public override string FileNameWithoutExtension { get { if (IsFileProtocol) { return System.IO.Path.GetFileName(Path); } return null; } } /// /// Gets or Sets the actual children. /// /// The actual children. [JsonIgnore] public virtual IEnumerable Children { get => _children ??= LoadChildren(); set => _children = value; } /// /// Gets thread-safe access to all recursive children of this folder - without regard to user. /// /// The recursive children. [JsonIgnore] public IEnumerable RecursiveChildren => GetRecursiveChildren(); [JsonIgnore] protected virtual bool SupportsShortcutChildren => false; protected virtual bool FilterLinkedChildrenPerUser => false; [JsonIgnore] protected override bool SupportsOwnedItems => base.SupportsOwnedItems || SupportsShortcutChildren; [JsonIgnore] public virtual bool SupportsUserDataFromChildren { get { // These are just far too slow. if (this is ICollectionFolder) { return false; } if (this is UserView) { return false; } if (this is UserRootFolder) { return false; } if (this is Channel) { return false; } if (SourceType != SourceType.Library) { return false; } if (this is IItemByName) { if (this is not IHasDualAccess hasDualAccess || hasDualAccess.IsAccessedByName) { return false; } } return true; } } public static ICollectionManager CollectionManager { get; set; } public override bool CanDelete() { if (IsRoot) { return false; } return base.CanDelete(); } public override bool RequiresRefresh() { var baseResult = base.RequiresRefresh(); if (SupportsCumulativeRunTimeTicks && !RunTimeTicks.HasValue) { baseResult = true; } return baseResult; } /// /// Adds the child. /// /// The item. /// Unable to add + item.Name. public void AddChild(BaseItem item) { item.SetParent(this); if (item.Id.IsEmpty()) { item.Id = LibraryManager.GetNewItemId(item.Path, item.GetType()); } if (item.DateCreated == DateTime.MinValue) { item.DateCreated = DateTime.UtcNow; } if (item.DateModified == DateTime.MinValue) { item.DateModified = DateTime.UtcNow; } LibraryManager.CreateItem(item, this); } public override bool IsVisible(User user, bool skipAllowedTagsCheck = false) { if (this is ICollectionFolder && this is not BasePluginFolder) { var blockedMediaFolders = user.GetPreferenceValues(PreferenceKind.BlockedMediaFolders); if (blockedMediaFolders.Length > 0) { if (blockedMediaFolders.Contains(Id)) { return false; } } else { if (!user.HasPermission(PermissionKind.EnableAllFolders) && !user.GetPreferenceValues(PreferenceKind.EnabledFolders).Contains(Id)) { return false; } } } return base.IsVisible(user, skipAllowedTagsCheck); } /// /// Loads our children. Validation will occur externally. /// We want this synchronous. /// /// Returns children. protected virtual IReadOnlyList LoadChildren() { // logger.LogDebug("Loading children from {0} {1} {2}", GetType().Name, Id, Path); // just load our children from the repo - the library will be validated and maintained in other processes return GetCachedChildren(); } public override double? GetRefreshProgress() { return ProviderManager.GetRefreshProgress(Id); } public Task ValidateChildren(IProgress progress, CancellationToken cancellationToken) { return ValidateChildren(progress, new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken: cancellationToken); } /// /// Validates that the children of the folder still exist. /// /// The progress. /// The metadata refresh options. /// if set to true [recursive]. /// remove item even this folder is root. /// The cancellation token. /// Task. public Task ValidateChildren(IProgress progress, MetadataRefreshOptions metadataRefreshOptions, bool recursive = true, bool allowRemoveRoot = false, CancellationToken cancellationToken = default) { Children = null; // invalidate cached children. return ValidateChildrenInternal(progress, recursive, true, allowRemoveRoot, metadataRefreshOptions, metadataRefreshOptions.DirectoryService, cancellationToken); } private Dictionary GetActualChildrenDictionary() { var dictionary = new Dictionary(); Children = null; // invalidate cached children. var childrenList = Children.ToList(); foreach (var child in childrenList) { var id = child.Id; if (dictionary.ContainsKey(id)) { Logger.LogError( "Found folder containing items with duplicate id. Path: {Path}, Child Name: {ChildName}", Path ?? Name, child.Path ?? child.Name); } else { dictionary[id] = child; } } return dictionary; } /// /// Validates the children internal. /// /// The progress. /// if set to true [recursive]. /// if set to true [refresh child metadata]. /// remove item even this folder is root. /// The refresh options. /// The directory service. /// The cancellation token. /// Task. protected virtual async Task ValidateChildrenInternal(IProgress progress, bool recursive, bool refreshChildMetadata, bool allowRemoveRoot, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) { if (recursive) { ProviderManager.OnRefreshStart(this); } try { if (GetParents().Any(f => f.Id.Equals(Id))) { throw new InvalidOperationException("Recursive datastructure detected abort processing this item."); } await ValidateChildrenInternal2(progress, recursive, refreshChildMetadata, allowRemoveRoot, refreshOptions, directoryService, cancellationToken).ConfigureAwait(false); } finally { if (recursive) { ProviderManager.OnRefreshComplete(this); } } } private static bool IsLibraryFolderAccessible(IDirectoryService directoryService, BaseItem item, bool checkCollection) { if (!checkCollection && (item is BoxSet || string.Equals(item.FileNameWithoutExtension, "collections", StringComparison.OrdinalIgnoreCase))) { return true; } // For top parents i.e. Library folders, skip the validation if it's empty or inaccessible if (item.IsTopParent && !directoryService.IsAccessible(item.ContainingFolderPath)) { Logger.LogWarning("Library folder {LibraryFolderPath} is inaccessible or empty, skipping", item.ContainingFolderPath); return false; } return true; } private async Task ValidateChildrenInternal2(IProgress progress, bool recursive, bool refreshChildMetadata, bool allowRemoveRoot, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) { if (!IsLibraryFolderAccessible(directoryService, this, allowRemoveRoot)) { return; } cancellationToken.ThrowIfCancellationRequested(); var validChildren = new List(); var validChildrenNeedGeneration = false; if (IsFileProtocol) { IEnumerable nonCachedChildren = []; try { nonCachedChildren = GetNonCachedChildren(directoryService); } catch (IOException ex) { Logger.LogError(ex, "Error retrieving children from file system"); } catch (SecurityException ex) { Logger.LogError(ex, "Error retrieving children from file system"); } catch (Exception ex) { Logger.LogError(ex, "Error retrieving children"); return; } progress.Report(ProgressHelpers.RetrievedChildren); if (recursive) { ProviderManager.OnRefreshProgress(this, ProgressHelpers.RetrievedChildren); } // Build a dictionary of the current children we have now by Id so we can compare quickly and easily var currentChildren = GetActualChildrenDictionary(); // Create a list for our validated children var newItems = new List(); var actuallyRemoved = new List(); // Build a reverse path→item lookup for detecting type changes var currentChildrenByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kvp in currentChildren) { if (!string.IsNullOrEmpty(kvp.Value.Path)) { currentChildrenByPath.TryAdd(kvp.Value.Path, kvp.Value); } } cancellationToken.ThrowIfCancellationRequested(); foreach (var child in nonCachedChildren) { if (!IsLibraryFolderAccessible(directoryService, child, allowRemoveRoot)) { continue; } if (currentChildren.TryGetValue(child.Id, out BaseItem currentChild)) { validChildren.Add(currentChild); if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None) { await currentChild.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); } else { // metadata is up-to-date; make sure DB has correct images dimensions and hash await LibraryManager.UpdateImagesAsync(currentChild).ConfigureAwait(false); } continue; } // Check if an existing item occupies the same path with different type/ID if (!string.IsNullOrEmpty(child.Path) && currentChildrenByPath.TryGetValue(child.Path, out var staleItem) && !staleItem.Id.Equals(child.Id)) { Logger.LogInformation( "Item type changed at {Path}: {OldType} -> {NewType}, removing stale entry", child.Path, staleItem.GetType().Name, child.GetType().Name); currentChildren.Remove(staleItem.Id); currentChildrenByPath.Remove(child.Path); staleItem.SetParent(null); LibraryManager.DeleteItem(staleItem, new DeleteOptions { DeleteFileLocation = false }, this, false); actuallyRemoved.Add(staleItem); } // Brand new item - needs to be added child.SetParent(this); newItems.Add(child); validChildren.Add(child); } // That's all the new and changed ones - now see if any have been removed and need cleanup var itemsRemoved = currentChildren.Values.Except(validChildren).ToList(); var shouldRemove = !IsRoot || allowRemoveRoot; // If it's an AggregateFolder, don't remove // Collect replaced primaries for deferred deletion (after CreateItems) var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>(); if (shouldRemove && itemsRemoved.Count > 0) { // Build a set of paths that are alternate versions of valid children // These items should not be deleted - they're managed by their primary video var alternateVersionPaths = validChildren .OfType