aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations/Library
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations/Library')
-rw-r--r--Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs273
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs519
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs2
-rw-r--r--Emby.Server.Implementations/Library/UserDataManager.cs87
-rw-r--r--Emby.Server.Implementations/Library/UserViewManager.cs52
-rw-r--r--Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs35
-rw-r--r--Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs2
-rw-r--r--Emby.Server.Implementations/Library/Validators/GenresValidator.cs40
-rw-r--r--Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs17
-rw-r--r--Emby.Server.Implementations/Library/Validators/PeopleValidator.cs2
-rw-r--r--Emby.Server.Implementations/Library/Validators/StudiosValidator.cs40
11 files changed, 881 insertions, 188 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/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/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 ef20ae9bca..fa7112eb90 100644
--- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
@@ -55,25 +55,35 @@ public class ArtistsValidator
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();
+ }
+
+ // 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)
@@ -89,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);
}
}