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/LibraryManager.cs436
-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
9 files changed, 580 insertions, 131 deletions
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index eee87c4d8b..2bcb10e9e1 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -34,14 +34,11 @@ 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.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,6 +74,10 @@ 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;
@@ -115,6 +116,10 @@ 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>
@@ -133,6 +138,10 @@ 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,
@@ -151,6 +160,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);
@@ -327,9 +340,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 +384,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 +427,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 +564,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 +690,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 +774,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 +1213,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)
@@ -1186,7 +1358,7 @@ namespace Emby.Server.Implementations.Library
if (toDelete.Count > 0)
{
- _itemRepository.DeleteItem(toDelete.ToArray());
+ _persistenceService.DeleteItem(toDelete.ToArray());
}
}
@@ -1262,7 +1434,7 @@ namespace Emby.Server.Implementations.Library
progress.Report(percent * 100);
}
- _itemRepository.UpdateInheritedValues();
+ _persistenceService.UpdateInheritedValues();
progress.Report(100);
}
@@ -1421,14 +1593,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 +1617,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 +1636,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 +1704,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)
@@ -1700,6 +1898,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 +1928,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 +2102,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 +2243,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 +2429,7 @@ namespace Emby.Server.Implementations.Library
item.ValidateImages();
- await _itemRepository.SaveImagesAsync(item).ConfigureAwait(false);
+ await _persistenceService.SaveImagesAsync(item).ConfigureAwait(false);
RegisterItem(item);
}
@@ -2161,7 +2446,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 +2533,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)
@@ -2834,7 +3162,8 @@ namespace Emby.Server.Implementations.Library
{
// 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 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 +3225,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;
}
}
@@ -3385,5 +3720,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/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);
}
}