diff options
Diffstat (limited to 'Emby.Server.Implementations/Library')
13 files changed, 914 insertions, 189 deletions
diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs index ef5d24c70f..023c1e8915 100644 --- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; +using BitFaster.Caching.Lru; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Resolvers; @@ -15,22 +17,36 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule { private static readonly bool IsWindows = OperatingSystem.IsWindows(); - private static FileInfo? FindIgnoreFile(DirectoryInfo directory) - { - for (var current = directory; current is not null; current = current.Parent) - { - var ignorePath = Path.Join(current.FullName, ".ignore"); - if (File.Exists(ignorePath)) - { - return new FileInfo(ignorePath); - } - } + private readonly FastConcurrentLru<string, IgnoreFileCacheEntry> _directoryCache; + private readonly FastConcurrentLru<string, ParsedIgnoreCacheEntry> _rulesCache; - return null; + /// <summary> + /// Initializes a new instance of the <see cref="DotIgnoreIgnoreRule"/> class. + /// </summary> + public DotIgnoreIgnoreRule() + { + var cacheSize = Math.Max(100, Environment.ProcessorCount * 100); + _directoryCache = new FastConcurrentLru<string, IgnoreFileCacheEntry>( + Environment.ProcessorCount, + cacheSize, + StringComparer.Ordinal); + _rulesCache = new FastConcurrentLru<string, ParsedIgnoreCacheEntry>( + Environment.ProcessorCount, + Math.Max(32, cacheSize / 4), + StringComparer.Ordinal); } /// <inheritdoc /> - public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent); + public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnoredInternal(fileInfo, parent); + + /// <summary> + /// Clears the directory lookup cache. The parsed rules cache is not cleared + /// as it validates file modification time on each access. + /// </summary> + public void ClearDirectoryCache() + { + _directoryCache.Clear(); + } /// <summary> /// Checks whether or not the file is ignored. @@ -38,40 +54,38 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule /// <param name="fileInfo">The file information.</param> /// <param name="parent">The parent BaseItem.</param> /// <returns>True if the file should be ignored.</returns> - public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent) + public bool IsIgnoredInternal(FileSystemMetadata fileInfo, BaseItem? parent) { var searchDirectory = fileInfo.IsDirectory - ? new DirectoryInfo(fileInfo.FullName) - : new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty); + ? fileInfo.FullName + : Path.GetDirectoryName(fileInfo.FullName); - if (string.IsNullOrEmpty(searchDirectory.FullName)) + if (string.IsNullOrEmpty(searchDirectory)) { return false; } - var ignoreFile = FindIgnoreFile(searchDirectory); + var ignoreFile = FindIgnoreFileCached(searchDirectory); if (ignoreFile is null) { return false; } - // Fast path in case the ignore files isn't a symlink and is empty - if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0) + var parsedEntry = GetParsedRules(ignoreFile); + if (parsedEntry is null) { - // Ignore directory if we just have the file - return true; + // File was deleted after we cached the path - clear the directory cache entry and return false + _directoryCache.TryRemove(searchDirectory, out _); + return false; } - var content = GetFileContent(ignoreFile); - return string.IsNullOrWhiteSpace(content) - || CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory); - } + // Empty file means ignore everything + if (parsedEntry.IsEmpty) + { + return true; + } - private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory) - { - // If file has content, base ignoring off the content .gitignore-style rules - var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - return CheckIgnoreRules(path, rules, isDirectory); + return parsedEntry.Rules.IsIgnored(GetPathToCheck(fileInfo.FullName, fileInfo.IsDirectory)); } /// <summary> @@ -117,8 +131,8 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule return true; } - // Mitigate the problem of the Ignore library not handling Windows paths correctly. - // See https://github.com/jellyfin/jellyfin/issues/15484 + // Mitigate the problem of the Ignore library not handling Windows paths correctly. + // See https://github.com/jellyfin/jellyfin/issues/15484 var pathToCheck = normalizePath ? path.NormalizePath('/') : path; // Add trailing slash for directories to match "folder/" @@ -130,11 +144,196 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule return ignore.IsIgnored(pathToCheck); } - private static string GetFileContent(FileInfo ignoreFile) + private FileInfo? FindIgnoreFileCached(string directory) + { + // Check if we have a cached result for this directory + if (_directoryCache.TryGet(directory, out var cached)) + { + return cached.IgnoreFileDirectory is null + ? null + : new FileInfo(Path.Join(cached.IgnoreFileDirectory, ".ignore")); + } + + DirectoryInfo startDir; + try + { + startDir = new DirectoryInfo(directory); + } + catch (ArgumentException) + { + return null; + } + + // Walk up the directory tree to find .ignore file using DirectoryInfo.Parent + var checkedDirs = new List<string> { directory }; + + for (var current = startDir; current is not null; current = current.Parent) + { + var currentPath = current.FullName; + + // Check if this intermediate directory is cached + if (current != startDir && _directoryCache.TryGet(currentPath, out var parentCached)) + { + // Cache the result for all directories we checked + var entry = new IgnoreFileCacheEntry(parentCached.IgnoreFileDirectory); + foreach (var dir in checkedDirs) + { + _directoryCache.AddOrUpdate(dir, entry); + } + + return parentCached.IgnoreFileDirectory is null + ? null + : new FileInfo(Path.Join(parentCached.IgnoreFileDirectory, ".ignore")); + } + + var ignoreFile = new FileInfo(Path.Join(currentPath, ".ignore")); + if (ignoreFile.Exists) + { + // Cache for all directories we checked + var entry = new IgnoreFileCacheEntry(currentPath); + foreach (var dir in checkedDirs) + { + _directoryCache.AddOrUpdate(dir, entry); + } + + return ignoreFile; + } + + if (current != startDir) + { + checkedDirs.Add(currentPath); + } + } + + // No .ignore file found - cache null result for all directories + var nullEntry = new IgnoreFileCacheEntry((string?)null); + foreach (var dir in checkedDirs) + { + _directoryCache.AddOrUpdate(dir, nullEntry); + } + + return null; + } + + private ParsedIgnoreCacheEntry? GetParsedRules(FileInfo ignoreFile) + { + if (!ignoreFile.Exists) + { + _rulesCache.TryRemove(ignoreFile.FullName, out _); + return null; + } + + var lastModified = ignoreFile.LastWriteTimeUtc; + var fileLength = ignoreFile.Length; + var key = ignoreFile.FullName; + + // Check cache + if (_rulesCache.TryGet(key, out var cached)) + { + if (cached.FileLastModified == lastModified && cached.FileLength == fileLength) + { + return cached; + } + + // Stale - need to reparse + _rulesCache.TryRemove(key, out _); + } + + // Parse the file + var parsedEntry = ParseIgnoreFile(ignoreFile, lastModified, fileLength); + _rulesCache.AddOrUpdate(key, parsedEntry); + return parsedEntry; + } + + private static ParsedIgnoreCacheEntry ParseIgnoreFile(FileInfo ignoreFile, DateTime lastModified, long fileLength) { - ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile; - return ignoreFile.Exists - ? File.ReadAllText(ignoreFile.FullName) - : string.Empty; + if (ignoreFile.LinkTarget is null && fileLength == 0) + { + return new ParsedIgnoreCacheEntry + { + Rules = new Ignore.Ignore(), + FileLastModified = lastModified, + FileLength = fileLength, + IsEmpty = true + }; + } + + // Resolve symlinks + var resolvedFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile; + if (!resolvedFile.Exists) + { + return new ParsedIgnoreCacheEntry + { + Rules = new Ignore.Ignore(), + FileLastModified = lastModified, + FileLength = fileLength, + IsEmpty = true + }; + } + + var content = File.ReadAllText(resolvedFile.FullName); + if (string.IsNullOrWhiteSpace(content)) + { + return new ParsedIgnoreCacheEntry + { + Rules = new Ignore.Ignore(), + FileLastModified = lastModified, + FileLength = fileLength, + IsEmpty = true + }; + } + + var rules = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var ignore = new Ignore.Ignore(); + var validRulesAdded = 0; + + foreach (var rule in rules) + { + try + { + ignore.Add(rule); + validRulesAdded++; + } + catch (RegexParseException) + { + // Ignore invalid patterns + } + } + + // No valid rules means treat as empty (ignore all) + return new ParsedIgnoreCacheEntry + { + Rules = ignore, + FileLastModified = lastModified, + FileLength = fileLength, + IsEmpty = validRulesAdded == 0 + }; + } + + private static string GetPathToCheck(string path, bool isDirectory) + { + // Normalize Windows paths + var pathToCheck = IsWindows ? path.NormalizePath('/') : path; + + // Add trailing slash for directories to match "folder/" + if (isDirectory) + { + pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/"); + } + + return pathToCheck; + } + + private readonly record struct IgnoreFileCacheEntry(string? IgnoreFileDirectory); + + private sealed class ParsedIgnoreCacheEntry + { + public required Ignore.Ignore Rules { get; init; } + + public required DateTime FileLastModified { get; init; } + + public required long FileLength { get; init; } + + public required bool IsEmpty { get; init; } } } diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs index 59ccb9e2c7..197ec42c50 100644 --- a/Emby.Server.Implementations/Library/IgnorePatterns.cs +++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs @@ -31,6 +31,20 @@ namespace Emby.Server.Implementations.Library "**/*.sample.?????", "**/sample/*", + // Avoid adding Hungarian sample files + // https://github.com/jellyfin/jellyfin/issues/16237 + "**/minta.?", + "**/minta.??", + "**/minta.???", // Matches minta.mkv + "**/minta.????", // Matches minta.webm + "**/minta.?????", + "**/*.minta.?", + "**/*.minta.??", + "**/*.minta.???", + "**/*.minta.????", + "**/*.minta.?????", + "**/minta/*", + // Directories "**/metadata/**", "**/metadata", diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index eee87c4d8b..11f1496086 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -30,18 +30,17 @@ using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Controller.Sorting; -using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -77,12 +76,17 @@ namespace Emby.Server.Implementations.Library private readonly IMediaEncoder _mediaEncoder; private readonly IFileSystem _fileSystem; private readonly IItemRepository _itemRepository; + private readonly IItemPersistenceService _persistenceService; + private readonly INextUpService _nextUpService; + private readonly IItemCountService _countService; + private readonly ILinkedChildrenService _linkedChildrenService; private readonly IImageProcessor _imageProcessor; private readonly NamingOptions _namingOptions; private readonly IPeopleRepository _peopleRepository; private readonly ExtraResolver _extraResolver; private readonly IPathManager _pathManager; private readonly FastConcurrentLru<Guid, BaseItem> _cache; + private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; /// <summary> /// The _root folder sync lock. @@ -115,11 +119,16 @@ namespace Emby.Server.Implementations.Library /// <param name="userViewManagerFactory">The user view manager.</param> /// <param name="mediaEncoder">The media encoder.</param> /// <param name="itemRepository">The item repository.</param> + /// <param name="persistenceService">The item persistence service.</param> + /// <param name="nextUpService">The next up service.</param> + /// <param name="countService">The item count service.</param> + /// <param name="linkedChildrenService">The linked children service.</param> /// <param name="imageProcessor">The image processor.</param> /// <param name="namingOptions">The naming options.</param> /// <param name="directoryService">The directory service.</param> /// <param name="peopleRepository">The people repository.</param> /// <param name="pathManager">The path manager.</param> + /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> public LibraryManager( IServerApplicationHost appHost, ILoggerFactory loggerFactory, @@ -133,11 +142,16 @@ namespace Emby.Server.Implementations.Library Lazy<IUserViewManager> userViewManagerFactory, IMediaEncoder mediaEncoder, IItemRepository itemRepository, + IItemPersistenceService persistenceService, + INextUpService nextUpService, + IItemCountService countService, + ILinkedChildrenService linkedChildrenService, IImageProcessor imageProcessor, NamingOptions namingOptions, IDirectoryService directoryService, IPeopleRepository peopleRepository, - IPathManager pathManager) + IPathManager pathManager, + DotIgnoreIgnoreRule dotIgnoreIgnoreRule) { _appHost = appHost; _logger = loggerFactory.CreateLogger<LibraryManager>(); @@ -151,6 +165,10 @@ namespace Emby.Server.Implementations.Library _userViewManagerFactory = userViewManagerFactory; _mediaEncoder = mediaEncoder; _itemRepository = itemRepository; + _persistenceService = persistenceService; + _nextUpService = nextUpService; + _countService = countService; + _linkedChildrenService = linkedChildrenService; _imageProcessor = imageProcessor; _cache = new FastConcurrentLru<Guid, BaseItem>(_configurationManager.Configuration.CacheSize); @@ -158,6 +176,7 @@ namespace Emby.Server.Implementations.Library _namingOptions = namingOptions; _peopleRepository = peopleRepository; _pathManager = pathManager; + _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService); _configurationManager.ConfigurationUpdated += ConfigurationUpdated; @@ -327,9 +346,17 @@ namespace Emby.Server.Implementations.Library DeleteItem(item, options, parent, notifyParentItem); } - public void DeleteItemsUnsafeFast(IEnumerable<BaseItem> items) + public void DeleteItemsUnsafeFast(IReadOnlyCollection<BaseItem> items, bool deleteSourceFiles = false) { - var pathMaps = items.Select(e => (Item: e, InternalPath: GetInternalMetadataPaths(e), DeletePaths: e.GetDeletePaths())).ToArray(); + if (items.Count == 0) + { + return; + } + + var pathMaps = items.Select(e => + (Item: e, + InternalPath: GetInternalMetadataPaths(e), + DeletePaths: deleteSourceFiles ? e.GetDeletePaths() : [])).ToArray(); foreach (var (item, internalPaths, pathsToDelete) in pathMaps) { @@ -363,7 +390,7 @@ namespace Emby.Server.Implementations.Library } } - _itemRepository.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]); + _persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]); } public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem) @@ -406,6 +433,99 @@ namespace Emby.Server.Implementations.Library item.Id); } + // If deleting a primary version video, clear PrimaryVersionId from alternate versions + // OwnerId check: items with OwnerId set are alternate versions or extras, not primaries + if (item is Video video && !video.PrimaryVersionId.HasValue && video.OwnerId.IsEmpty()) + { + var localAlternateIds = GetLocalAlternateVersionIds(video).ToHashSet(); + var allAlternateVersions = localAlternateIds + .Concat(GetLinkedAlternateVersions(video).Select(v => v.Id)) + .Distinct() + .Select(id => GetItemById(id)) + .OfType<Video>() + .ToList(); + + // Partition alternates by whether their files still exist on disk + var alternateVersions = new List<Video>(); + var missingAlternates = new List<Video>(); + foreach (var alt in allAlternateVersions) + { + if (!string.IsNullOrEmpty(alt.Path) && !_fileSystem.FileExists(alt.Path)) + { + missingAlternates.Add(alt); + } + else + { + alternateVersions.Add(alt); + } + } + + // Delete alternates whose files no longer exist to avoid ghost items. + // Clear PrimaryVersionId first so DeleteItem doesn't try to update the primary being deleted. + foreach (var missing in missingAlternates) + { + _logger.LogInformation( + "Deleting missing alternate version {Name} ({Path})", + missing.Name ?? "Unknown name", + missing.Path ?? string.Empty); + missing.SetPrimaryVersionId(null); + missing.OwnerId = Guid.Empty; + missing.LocalAlternateVersions = []; + missing.LinkedAlternateVersions = []; + DeleteItem(missing, new DeleteOptions { DeleteFileLocation = false }, false); + } + + if (alternateVersions.Count > 0) + { + _logger.LogInformation( + "Clearing PrimaryVersionId from {Count} alternate versions of {Name}", + alternateVersions.Count, + item.Name ?? "Unknown name"); + + // Promote the first alternate version to be the new primary + var newPrimary = alternateVersions[0]; + newPrimary.SetPrimaryVersionId(null); + newPrimary.OwnerId = Guid.Empty; + + // Transfer alternate version arrays from old primary to new primary + // so UpdateToRepositoryAsync creates correct LinkedChildren entries + newPrimary.LocalAlternateVersions = video.LocalAlternateVersions + .Where(p => !string.Equals(p, newPrimary.Path, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + newPrimary.LinkedAlternateVersions = video.LinkedAlternateVersions + .Where(lc => !lc.ItemId.HasValue || !lc.ItemId.Value.Equals(newPrimary.Id)) + .ToArray(); + + newPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + + // Re-route playlist/collection references from deleted primary to new primary + RerouteLinkedChildReferencesAsync(video.Id, newPrimary.Id).GetAwaiter().GetResult(); + + // Update remaining alternates to point to new primary + foreach (var alternate in alternateVersions.Skip(1)) + { + alternate.SetPrimaryVersionId(newPrimary.Id); + // Only set OwnerId for local alternates; linked alternates are independent items + alternate.OwnerId = localAlternateIds.Contains(alternate.Id) ? newPrimary.Id : Guid.Empty; + alternate.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + } + } + } + else if (item is Video alternateVideo && alternateVideo.PrimaryVersionId.HasValue) + { + // If deleting an alternate version, re-route references to its primary + RerouteLinkedChildReferencesAsync(alternateVideo.Id, alternateVideo.PrimaryVersionId.Value).GetAwaiter().GetResult(); + + // Remove deleted alternate from primary's LinkedAlternateVersions + if (GetItemById(alternateVideo.PrimaryVersionId.Value) is Video primaryVideo) + { + primaryVideo.LinkedAlternateVersions = primaryVideo.LinkedAlternateVersions + .Where(lc => !lc.ItemId.HasValue || !lc.ItemId.Value.Equals(alternateVideo.Id)) + .ToArray(); + primaryVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + } + } + var children = item.IsFolder ? ((Folder)item).GetRecursiveChildren(false) : []; @@ -450,7 +570,7 @@ namespace Emby.Server.Implementations.Library item.SetParent(null); - _itemRepository.DeleteItem([item.Id, .. children.Select(f => f.Id)]); + _persistenceService.DeleteItem([item.Id, .. children.Select(f => f.Id)]); _cache.TryRemove(item.Id, out _); foreach (var child in children) { @@ -576,6 +696,9 @@ namespace Emby.Server.Implementations.Library // Trickplay list.Add(_pathManager.GetTrickplayDirectory(video)); + // Chapter Images + list.Add(_pathManager.GetChapterImageFolderPath(video)); + // Subtitles and attachments foreach (var mediaSource in item.GetMediaSources(false)) { @@ -657,8 +780,63 @@ namespace Emby.Server.Implementations.Library return key.GetMD5(); } - public BaseItem? ResolvePath(FileSystemMetadata fileInfo, Folder? parent = null, IDirectoryService? directoryService = null) - => ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent); + public BaseItem? ResolvePath( + FileSystemMetadata fileInfo, + Folder? parent = null, + IDirectoryService? directoryService = null, + CollectionType? collectionType = null) + => ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent, collectionType); + + /// <inheritdoc /> + public Video? ResolveAlternateVersion(string path, Type expectedVideoType, Folder? parent, CollectionType? collectionType) + { + // Clean up any existing item saved with wrong type (e.g. Video instead of Movie). + // This happens when items were previously resolved without proper type context + // in mixed-content libraries where collectionType is null. + var expectedId = GetNewItemId(path, expectedVideoType); + if (expectedVideoType != typeof(Video)) + { + var wrongTypeId = GetNewItemId(path, typeof(Video)); + if (!wrongTypeId.Equals(expectedId) && GetItemById(wrongTypeId) is Video wrongTypeItem) + { + _logger.LogInformation( + "Removing alternate version with wrong type {WrongType}, expected {ExpectedType}: {Path}", + wrongTypeItem.GetType().Name, + expectedVideoType.Name, + path); + DeleteItem(wrongTypeItem, new DeleteOptions { DeleteFileLocation = false }); + } + } + + var resolved = ResolvePath( + _fileSystem.GetFileSystemInfo(path), + parent, + collectionType: collectionType) as Video; + + if (resolved is null) + { + return null; + } + + // Ensure the alternate version has the same concrete type as the primary video. + // ResolvePath may return a generic Video for files in mixed-content libraries + // where collectionType is null, even though the primary is a Movie/Episode/etc. + if (resolved.GetType() != expectedVideoType) + { + if (Activator.CreateInstance(expectedVideoType) is Video correctVideo) + { + correctVideo.Path = resolved.Path; + correctVideo.Name = resolved.Name; + correctVideo.VideoType = resolved.VideoType; + correctVideo.ProductionYear = resolved.ProductionYear; + correctVideo.ExtraType = resolved.ExtraType; + resolved = correctVideo; + } + } + + resolved.Id = expectedId; + return resolved; + } private BaseItem? ResolvePath( FileSystemMetadata fileInfo, @@ -1041,7 +1219,7 @@ namespace Emby.Server.Implementations.Library public IReadOnlyDictionary<string, MusicArtist[]> GetArtists(IReadOnlyList<string> names) { - return _itemRepository.FindArtists(names); + return _linkedChildrenService.FindArtists(names); } public MusicArtist GetArtist(string name, DtoOptions options) @@ -1131,6 +1309,7 @@ namespace Emby.Server.Implementations.Library public async Task ValidateMediaLibraryInternal(IProgress<double> progress, CancellationToken cancellationToken) { IsScanRunning = true; + ClearIgnoreRuleCache(); LibraryMonitor.Stop(); try @@ -1139,6 +1318,7 @@ namespace Emby.Server.Implementations.Library } finally { + ClearIgnoreRuleCache(); LibraryMonitor.Start(); IsScanRunning = false; } @@ -1146,6 +1326,7 @@ namespace Emby.Server.Implementations.Library public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false) { + ClearIgnoreRuleCache(); RootFolder.Children = null; await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false); @@ -1186,8 +1367,16 @@ namespace Emby.Server.Implementations.Library if (toDelete.Count > 0) { - _itemRepository.DeleteItem(toDelete.ToArray()); + _persistenceService.DeleteItem(toDelete.ToArray()); } + + ClearIgnoreRuleCache(); + } + + /// <inheritdoc /> + public void ClearIgnoreRuleCache() + { + _dotIgnoreIgnoreRule.ClearDirectoryCache(); } private async Task PerformLibraryValidation(IProgress<double> progress, CancellationToken cancellationToken) @@ -1262,7 +1451,7 @@ namespace Emby.Server.Implementations.Library progress.Report(percent * 100); } - _itemRepository.UpdateInheritedValues(); + _persistenceService.UpdateInheritedValues(); progress.Report(100); } @@ -1421,14 +1610,7 @@ namespace Emby.Server.Implementations.Library AddUserToQuery(query, query.User, allowExternalContent); } - var itemList = _itemRepository.GetItemList(query); - var user = query.User; - if (user is not null) - { - return itemList.Where(i => i.IsVisible(user)).ToList(); - } - - return itemList; + return _itemRepository.GetItemList(query); } public IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery query) @@ -1452,7 +1634,7 @@ namespace Emby.Server.Implementations.Library AddUserToQuery(query, query.User); } - return _itemRepository.GetCount(query); + return _countService.GetCount(query); } public ItemCounts GetItemCounts(InternalItemsQuery query) @@ -1471,7 +1653,30 @@ namespace Emby.Server.Implementations.Library AddUserToQuery(query, query.User); } - return _itemRepository.GetItemCounts(query); + return _countService.GetItemCounts(query); + } + + /// <inheritdoc/> + public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, User? user) + { + var query = new InternalItemsQuery(user); + if (user is not null) + { + AddUserToQuery(query, user); + } + + return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query); + } + + public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId) + { + return _countService.GetChildCountBatch(parentIds, userId); + } + + /// <inheritdoc/> + public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user) + { + return _countService.GetPlayedAndTotalCountBatch(folderIds, user); } public IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery query, List<BaseItem> parents) @@ -1516,7 +1721,17 @@ namespace Emby.Server.Implementations.Library } } - return _itemRepository.GetNextUpSeriesKeys(query, dateCutoff); + return _nextUpService.GetNextUpSeriesKeys(query, dateCutoff); + } + + /// <inheritdoc /> + public IReadOnlyDictionary<string, MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery query, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching) + { + return _nextUpService.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeWatchedForRewatching); } public QueryResult<BaseItem> QueryItems(InternalItemsQuery query) @@ -1683,6 +1898,25 @@ namespace Emby.Server.Implementations.Library query.TopParentIds = [Guid.NewGuid()]; } } + else if (parents.Count == 1 && parents.First() is Folder folder + && (folder is Playlist || folder is BoxSet) + && folder.LinkedChildren.Length > 0) + { + // Playlists and BoxSets store their contents in LinkedChildren and never + // populate AncestorIds for those items, so a recursive AncestorIds query + // would return zero rows. Resolve to the linked child IDs up front and + // route through the existing indexed ItemIds filter. + query.ItemIds = folder.LinkedChildren + .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty()) + .Select(lc => lc.ItemId!.Value) + .ToArray(); + + // Empty linked-children should still return empty rather than scanning everything. + if (query.ItemIds.Length == 0) + { + query.ItemIds = [Guid.NewGuid()]; + } + } else { // We need to be able to query from any arbitrary ancestor up the tree @@ -1700,6 +1934,11 @@ namespace Emby.Server.Implementations.Library private void AddUserToQuery(InternalItemsQuery query, User user, bool allowExternalContent = true) { + if (query.User is null) + { + query.SetUser(user); + } + if (query.AncestorIds.Length == 0 && query.ParentId.IsEmpty() && query.ChannelIds.Count == 0 && @@ -1725,6 +1964,15 @@ namespace Emby.Server.Implementations.Library } } + /// <inheritdoc/> + public void ConfigureUserAccess(InternalItemsQuery query, User user) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(user); + + AddUserToQuery(query, user); + } + private IEnumerable<Guid> GetTopParentIdsForQuery(BaseItem item, User? user) { if (item is UserView view) @@ -1890,6 +2138,44 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> + public IEnumerable<Guid> GetLocalAlternateVersionIds(Video video) + { + ArgumentNullException.ThrowIfNull(video); + + var linkedIds = _linkedChildrenService.GetLinkedChildrenIds(video.Id, (int)MediaBrowser.Controller.Entities.LinkedChildType.LocalAlternateVersion); + if (linkedIds.Count > 0) + { + return linkedIds; + } + + return []; + } + + /// <inheritdoc /> + public IEnumerable<Video> GetLinkedAlternateVersions(Video video) + { + ArgumentNullException.ThrowIfNull(video); + + var linkedIds = _linkedChildrenService.GetLinkedChildrenIds(video.Id, (int)MediaBrowser.Controller.Entities.LinkedChildType.LinkedAlternateVersion); + if (linkedIds.Count > 0) + { + return linkedIds + .Select(id => GetItemById(id)) + .Where(i => i is not null) + .OfType<Video>() + .OrderBy(i => i.SortName); + } + + return []; + } + + /// <inheritdoc /> + public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType) + { + _linkedChildrenService.UpsertLinkedChild(parentId, childId, childType); + } + + /// <inheritdoc /> public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder) { IOrderedEnumerable<BaseItem>? orderedItems = null; @@ -1993,10 +2279,45 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public void CreateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken) { - _itemRepository.SaveItems(items, cancellationToken); - + // Resolve and add any local alternate version items that don't exist yet + // This ensures they exist in the database when LinkedChildren are processed + var allItems = new List<BaseItem>(items); + var parentFolder = parent as Folder; + var parentCollectionType = parent is not null ? GetTopFolderContentType(parent) : null; foreach (var item in items) { + if (item is Video video && video.LocalAlternateVersions.Length > 0) + { + var videoType = video.GetType(); + foreach (var path in video.LocalAlternateVersions) + { + if (string.IsNullOrEmpty(path)) + { + continue; + } + + // Use the primary video's type for ID calculation to ensure consistency + var altId = GetNewItemId(path, videoType); + if (GetItemById(altId) is null && !allItems.Any(i => i.Id.Equals(altId))) + { + // Alternate version doesn't exist, resolve and create it + // ensuring it has the same type as the primary video + var altVideo = ResolveAlternateVersion(path, videoType, parentFolder, parentCollectionType); + if (altVideo is not null) + { + altVideo.OwnerId = video.Id; + altVideo.SetPrimaryVersionId(video.Id); + allItems.Add(altVideo); + } + } + } + } + } + + _persistenceService.SaveItems(allItems, cancellationToken); + + foreach (var item in allItems) + { RegisterItem(item); } @@ -2144,7 +2465,7 @@ namespace Emby.Server.Implementations.Library item.ValidateImages(); - await _itemRepository.SaveImagesAsync(item).ConfigureAwait(false); + await _persistenceService.SaveImagesAsync(item).ConfigureAwait(false); RegisterItem(item); } @@ -2161,7 +2482,50 @@ namespace Emby.Server.Implementations.Library item.DateLastSaved = DateTime.UtcNow; } - _itemRepository.SaveItems(items, cancellationToken); + // Resolve and add any local alternate version items that don't exist yet + // This ensures they exist in the database when LinkedChildren are processed + var allItems = new List<BaseItem>(items); + var parentFolder = parent as Folder; + var parentCollectionType = GetTopFolderContentType(parent); + foreach (var item in items) + { + if (item is Video video && video.LocalAlternateVersions.Length > 0) + { + var videoType = video.GetType(); + foreach (var path in video.LocalAlternateVersions) + { + if (string.IsNullOrEmpty(path)) + { + continue; + } + + // Use the primary video's type for ID calculation to ensure consistency + var altId = GetNewItemId(path, videoType); + if (GetItemById(altId) is null && !allItems.Any(i => i.Id.Equals(altId))) + { + // Alternate version doesn't exist, resolve and create it + // ensuring it has the same type as the primary video + var altVideo = ResolveAlternateVersion(path, videoType, parentFolder, parentCollectionType); + if (altVideo is not null) + { + altVideo.OwnerId = video.Id; + altVideo.SetPrimaryVersionId(video.Id); + allItems.Add(altVideo); + } + } + } + } + } + + _persistenceService.SaveItems(allItems, cancellationToken); + + foreach (var item in allItems) + { + if (!items.Contains(item)) + { + RegisterItem(item); + } + } if (parent is Folder folder) { @@ -2205,7 +2569,7 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken) { - await _itemRepository.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false); + await _persistenceService.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false); } public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason) @@ -2833,8 +3197,9 @@ namespace Emby.Server.Implementations.Library public IEnumerable<BaseItem> FindExtras(BaseItem owner, IReadOnlyList<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService) { // Apply .ignore rules - var filtered = fileSystemChildren.Where(c => !DotIgnoreIgnoreRule.IsIgnored(c, owner)).ToList(); - var ownerVideoInfo = VideoResolver.Resolve(owner.Path, owner.IsFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath); + var filtered = fileSystemChildren.Where(c => !_dotIgnoreIgnoreRule.ShouldIgnore(c, owner)).ToList(); + var isFolder = owner.IsFolder || (owner is Video video && (video.VideoType == VideoType.BluRay || video.VideoType == VideoType.Dvd)); + var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath); if (ownerVideoInfo is null) { yield break; @@ -2896,10 +3261,16 @@ namespace Emby.Server.Implementations.Library extra.ExtraType = extraType; } - extra.ParentId = Guid.Empty; - extra.OwnerId = owner.Id; - extra.IsInMixedFolder = isInMixedFolder; - return extra; + // Only return items that are actual extras (have ExtraType set) + // Note: OwnerId and ParentId are set by RefreshExtras, not here, + // so that RefreshExtras can detect when they need updating and set ForceSave. + if (extra.ExtraType is not null) + { + extra.IsInMixedFolder = isInMixedFolder; + return extra; + } + + return null; } } @@ -2918,7 +3289,7 @@ namespace Emby.Server.Implementations.Library public IReadOnlyList<PersonInfo> GetPeople(InternalPeopleQuery query) { - return _peopleRepository.GetPeople(query); + return _peopleRepository.GetPeople(query).Items; } public IReadOnlyList<PersonInfo> GetPeople(BaseItem item) @@ -2939,24 +3310,33 @@ namespace Emby.Server.Implementations.Library return []; } - public IReadOnlyList<Person> GetPeopleItems(InternalPeopleQuery query) + public QueryResult<BaseItem> GetPeopleItems(InternalPeopleQuery query) { - return _peopleRepository.GetPeopleNames(query) - .Select(i => - { - try + var queryResult = _peopleRepository.GetPeople(query); + var baseItems = queryResult.Items.Select(i => { - return GetPerson(i); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting person"); - return null; - } - }) - .Where(i => i is not null) - .Where(i => query.User is null || i!.IsVisible(query.User)) - .ToList()!; // null values are filtered out + try + { + return GetPerson(i.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "error retrieving BaseItem for person: {0}", i.Name); + return null; + } + }) + .Where(i => i is not null) + .Where(i => query.User is null || i!.IsVisible(query.User)) + .OfType<BaseItem>() + .ToList() + .AsReadOnly(); + + return new QueryResult<BaseItem> + { + StartIndex = queryResult.StartIndex, + TotalRecordCount = queryResult.TotalRecordCount, + Items = baseItems, + }; } public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query) @@ -3385,5 +3765,40 @@ namespace Emby.Server.Implementations.Library _fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path)); RemoveContentTypeOverrides(path); } + + /// <inheritdoc /> + public async Task RerouteLinkedChildReferencesAsync(Guid fromChildId, Guid toChildId) + { + var affectedParentIds = _linkedChildrenService.RerouteLinkedChildren(fromChildId, toChildId); + + // Update in-memory LinkedChildren and re-save metadata (NFO) for affected parents + foreach (var parentId in affectedParentIds) + { + if (GetItemById(parentId) is Folder parent) + { + foreach (var lc in parent.LinkedChildren) + { + if (lc.ItemId.HasValue && lc.ItemId.Value.Equals(fromChildId)) + { + lc.ItemId = toChildId; + } + } + + await RunMetadataSavers(parent, ItemUpdateType.MetadataEdit).ConfigureAwait(false); + } + } + } + + /// <inheritdoc /> + public QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + + SetTopParentOrAncestorIds(query); + return _itemRepository.GetQueryFiltersLegacy(query); + } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 3ee1c757f2..1e885aad6e 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books { public class BookResolver : ItemResolver<Book> { - private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf", ".m4b", ".m4a", ".aac", ".flac", ".mp3", ".opus" }; + private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" }; protected override Book Resolve(ItemResolveArgs args) { diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index ad041086e5..6e9a38fd34 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -3,6 +3,7 @@ using System; using System.Globalization; using System.IO; +using System.Linq; using Emby.Naming.Common; using Emby.Naming.TV; using Emby.Server.Implementations.Library; @@ -81,6 +82,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } + + var hasAnyVideo = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Any(file => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(file))); + + if (!hasAnyVideo) + { + return null; + } } if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name)) diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 72c8d7a9d2..1281f1587f 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -177,53 +177,74 @@ namespace Emby.Server.Implementations.Library }; } - private UserItemData? GetUserData(User user, Guid itemId, List<string> keys) + /// <inheritdoc /> + public Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user) { - var cacheKey = GetCacheKey(user.InternalId, itemId); + var result = new Dictionary<Guid, UserItemData>(items.Count); + var itemsNeedingQuery = new List<(BaseItem Item, List<string> Keys)>(); - if (_cache.TryGet(cacheKey, out var data)) + foreach (var item in items) { - return data; - } - - data = GetUserDataInternal(user.Id, itemId, keys); - - if (data is null) - { - return new UserItemData() + var cacheKey = GetCacheKey(user.InternalId, item.Id); + if (_cache.TryGet(cacheKey, out var cachedData)) { - Key = keys[0], - }; + result[item.Id] = cachedData; + } + else + { + var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault(); + if (userData is not null) + { + result[item.Id] = userData; + _cache.AddOrUpdate(cacheKey, userData); + } + else + { + var keys = item.GetUserDataKeys(); + itemsNeedingQuery.Add((item, keys)); + } + } } - return _cache.GetOrAdd(cacheKey, _ => data); - } - - private UserItemData? GetUserDataInternal(Guid userId, Guid itemId, List<string> keys) - { - if (keys.Count == 0) + if (itemsNeedingQuery.Count == 0) { - return null; + return result; } - using var context = _repository.CreateDbContext(); - var userData = context.UserData.AsNoTracking().Where(e => e.ItemId == itemId && keys.Contains(e.CustomDataKey) && e.UserId.Equals(userId)).ToArray(); - - if (userData.Length > 0) + // Build a single query for all missing items + var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList(); + var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList(); + if (allKeys.Count > 0) { - var directDataReference = userData.FirstOrDefault(e => e.CustomDataKey == itemId.ToString("N")); - if (directDataReference is not null) + using var context = _repository.CreateDbContext(); + var userDataArray = context.UserData + .AsNoTracking() + .Where(e => e.UserId.Equals(user.Id)) + .WhereOneOrMany(allItemIds, e => e.ItemId) + .WhereOneOrMany(allKeys, e => e.CustomDataKey) + .ToArray(); + + var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray()); + foreach (var (item, keys) in itemsNeedingQuery) { - return Map(directDataReference); - } + UserItemData userData; + if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0) + { + var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N")); + userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First()); + } + else + { + userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty }; + } - return Map(userData.First()); + result[item.Id] = userData; + var cacheKey = GetCacheKey(user.InternalId, item.Id); + _cache.AddOrUpdate(cacheKey, userData); + } } - return new UserItemData - { - Key = keys.Last()! - }; + return result; } /// <summary> diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 6fb53ff15d..9512b0ffd7 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -59,8 +59,8 @@ namespace Emby.Server.Implementations.Library var collectionFolder = folder as ICollectionFolder; var folderViewType = collectionFolder?.CollectionType; - // Playlist library requires special handling because the folder only references user playlists - if (folderViewType == CollectionType.playlists) + // Playlist and BoxSet libraries require special handling because the folder only references linked items + if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) { var items = folder.GetItemList(new InternalItemsQuery(user) { @@ -138,7 +138,7 @@ namespace Emby.Server.Implementations.Library list = list.Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes).Contains(i.Id)).ToList(); } - var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList(); + var sorted = _libraryManager.Sort(list, user, [ItemSortBy.SortName], SortOrder.Ascending).ToList(); var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews); return list @@ -205,7 +205,7 @@ namespace Emby.Server.Implementations.Library var libraryItems = GetItemsForLatestItems(request.User, request, options); var list = new List<Tuple<BaseItem, List<BaseItem>>>(); - + var containerIndexMap = new Dictionary<Guid, int>(); foreach (var item in libraryItems) { // Only grab the index container for media @@ -213,20 +213,16 @@ namespace Emby.Server.Implementations.Library if (container is null) { - list.Add(new Tuple<BaseItem, List<BaseItem>>(null, new List<BaseItem> { item })); + list.Add(new Tuple<BaseItem, List<BaseItem>>(null!, new List<BaseItem> { item })); + } + else if (containerIndexMap.TryGetValue(container.Id, out var existingIndex)) + { + list[existingIndex].Item2.Add(item); } else { - var current = list.FirstOrDefault(i => i.Item1 is not null && i.Item1.Id.Equals(container.Id)); - - if (current is not null) - { - current.Item2.Add(item); - } - else - { - list.Add(new Tuple<BaseItem, List<BaseItem>>(container, new List<BaseItem> { item })); - } + containerIndexMap[container.Id] = list.Count; + list.Add(new Tuple<BaseItem, List<BaseItem>>(container, new List<BaseItem> { item })); } if (list.Count >= request.Limit) @@ -255,7 +251,7 @@ namespace Emby.Server.Implementations.Library return _channelManager.GetLatestChannelItemsInternal( new InternalItemsQuery(user) { - ChannelIds = new[] { parentId }, + ChannelIds = [parentId], IsPlayed = request.IsPlayed, StartIndex = request.StartIndex, Limit = request.Limit, @@ -301,11 +297,11 @@ namespace Emby.Server.Implementations.Library { if (hasCollectionType.All(i => i.CollectionType == CollectionType.movies)) { - includeItemTypes = new[] { BaseItemKind.Movie }; + includeItemTypes = [BaseItemKind.Movie]; } else if (hasCollectionType.All(i => i.CollectionType == CollectionType.tvshows)) { - includeItemTypes = new[] { BaseItemKind.Episode }; + includeItemTypes = [BaseItemKind.Episode]; } } } @@ -344,29 +340,29 @@ namespace Emby.Server.Implementations.Library } var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Length == 0 - ? new[] - { + ? + [ BaseItemKind.Person, BaseItemKind.Studio, BaseItemKind.Year, BaseItemKind.MusicGenre, BaseItemKind.Genre - } + ] : Array.Empty<BaseItemKind>(); var query = new InternalItemsQuery(user) { IncludeItemTypes = includeItemTypes, - OrderBy = new[] - { + OrderBy = + [ (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending), (ItemSortBy.ProductionYear, SortOrder.Descending) - }, + ], IsFolder = includeItemTypes.Length == 0 ? false : null, ExcludeItemTypes = excludeItemTypes, IsVirtualItem = false, - Limit = limit * 5, + Limit = limit * 2, IsPlayed = isPlayed, DtoOptions = options, MediaTypes = mediaTypes @@ -394,6 +390,12 @@ namespace Emby.Server.Implementations.Library query.Limit = limit; return _libraryManager.GetLatestItemList(query, parents, CollectionType.music); } + + if (collectionType == CollectionType.movies) + { + query.Limit = limit; + return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies); + } } return _libraryManager.GetItemList(query, parents); diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs index 7cc851b73b..fa7112eb90 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs @@ -50,21 +50,40 @@ public class ArtistsValidator public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { var names = _itemRepo.GetAllArtistNames(); + var existingArtistIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.MusicArtist] + }).ToHashSet(); + + var existingArtists = _libraryManager.GetArtists(names); var numComplete = 0; var count = names.Count; + var refreshed = 0; foreach (var name in names) { try { - var item = _libraryManager.GetArtist(name); + MusicArtist? item = null; + if (existingArtists.TryGetValue(name, out var artists) && artists.Length > 0) + { + item = artists.OrderBy(i => i.IsAccessedByName ? 1 : 0).First(); + } - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + // Fall back to GetArtist if not found (creates new item if needed) + item ??= _libraryManager.GetArtist(name); + var isNew = !existingArtistIds.Contains(item.Id); + var neverRefreshed = item.DateLastRefreshed == default; + + if (isNew || neverRefreshed) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { - // Don't clutter the log throw; } catch (Exception ex) @@ -80,31 +99,24 @@ public class ArtistsValidator progress.Report(percent); } + _logger.LogInformation("Refreshed metadata for {RefreshedCount} new artists out of {TotalCount} total", refreshed, count); + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicArtist], IsDeadArtist = true, IsLocked = false - }).Cast<MusicArtist>().ToList(); + }).Cast<MusicArtist>() + .Where(item => item.IsAccessedByName) + .ToList(); foreach (var item in deadEntities) { - if (!item.IsAccessedByName) - { - continue; - } - _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name); - - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = false - }, - false); } + _libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true); + progress.Report(100); } } diff --git a/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs index e62c638ed6..e3ef75b9ee 100644 --- a/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs +++ b/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs @@ -74,7 +74,7 @@ public class CollectionPostScanTask : ILibraryPostScanTask foreach (var m in movies) { - if (m is Movie movie && !string.IsNullOrEmpty(movie.CollectionName)) + if (m is Movie movie && !string.IsNullOrEmpty(movie.CollectionName) && !movie.PrimaryVersionId.HasValue) { if (collectionNameMoviesMap.TryGetValue(movie.CollectionName, out var movieList)) { diff --git a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs index fbfc9f7d54..fc5a2fa0c5 100644 --- a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -48,17 +49,40 @@ public class GenresValidator public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { var names = _itemRepo.GetGenreNames(); + var existingGenreIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Genre] + }).ToHashSet(); + + var existingGenres = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Genre] + }).Cast<Genre>() + .GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); var numComplete = 0; var count = names.Count; + var refreshed = 0; foreach (var name in names) { try { - var item = _libraryManager.GetGenre(name); + Genre? item = null; + if (existingGenres.TryGetValue(name, out var existingGenre)) + { + item = existingGenre; + } + + // Fall back to GetGenre if not found (creates new item if needed) + item ??= _libraryManager.GetGenre(name); - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + if (!existingGenreIds.Contains(item.Id)) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { @@ -78,6 +102,8 @@ public class GenresValidator progress.Report(percent); } + _logger.LogInformation("Refreshed metadata for {RefreshedCount} new genres out of {TotalCount} total", refreshed, count); + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Genre, BaseItemKind.MusicGenre], @@ -88,16 +114,10 @@ public class GenresValidator foreach (var item in deadEntities) { _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name); - - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = false - }, - false); } + _libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true); + progress.Report(100); } } diff --git a/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs b/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs index 6203bce2bc..4365707529 100644 --- a/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs @@ -1,6 +1,9 @@ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using Microsoft.Extensions.Logging; @@ -45,17 +48,25 @@ public class MusicGenresValidator public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { var names = _itemRepo.GetMusicGenreNames(); + var existingMusicGenreIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.MusicGenre] + }).ToHashSet(); var numComplete = 0; var count = names.Count; + var refreshed = 0; foreach (var name in names) { try { var item = _libraryManager.GetMusicGenre(name); - - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + if (!existingMusicGenreIds.Contains(item.Id)) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { @@ -75,6 +86,8 @@ public class MusicGenresValidator progress.Report(percent); } + _logger.LogInformation("Refreshed metadata for {RefreshedCount} new music genres out of {TotalCount} total", refreshed, count); + progress.Report(100); } } diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index f9a6f0d19e..dacef102dd 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -109,7 +109,7 @@ public class PeopleValidator var i = 0; foreach (var item in deadEntities.Chunk(500)) { - _libraryManager.DeleteItemsUnsafeFast(item); + _libraryManager.DeleteItemsUnsafeFast(item, true); subProgress.Report(100f / deadEntities.Count * (i++ * 100)); } diff --git a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs index 5b87e4d9d0..88f86ae6ca 100644 --- a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -49,17 +50,40 @@ public class StudiosValidator public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { var names = _itemRepo.GetStudioNames(); + var existingStudioIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Studio] + }).ToHashSet(); + + var existingStudios = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Studio] + }).Cast<Studio>() + .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); var numComplete = 0; var count = names.Count; + var refreshed = 0; foreach (var name in names) { try { - var item = _libraryManager.GetStudio(name); + Studio? item = null; + if (existingStudios.TryGetValue(name, out var existingStudio)) + { + item = existingStudio; + } + + // Fall back to GetStudio if not found (creates new item if needed) + item ??= _libraryManager.GetStudio(name); - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + if (!existingStudioIds.Contains(item.Id)) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { @@ -79,6 +103,8 @@ public class StudiosValidator progress.Report(percent); } + _logger.LogInformation("Refreshed metadata for {RefreshedCount} new studios out of {TotalCount} total", refreshed, count); + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Studio], @@ -89,16 +115,10 @@ public class StudiosValidator foreach (var item in deadEntities) { _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name); - - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = false - }, - false); } + _libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true); + progress.Report(100); } } |
