Merge pull request #16062 from Shadowghost/perf-rebased

Query Performance Improvements
This commit is contained in:
Niels van Velzen
2026-05-03 21:56:34 +02:00
committed by GitHub
126 changed files with 27840 additions and 3962 deletions

View File

@@ -507,7 +507,13 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<IUserDataManager, UserDataManager>();
serviceCollection.AddSingleton<IItemRepository, BaseItemRepository>();
serviceCollection.AddSingleton<BaseItemRepository>();
serviceCollection.AddSingleton<IItemRepository>(sp => sp.GetRequiredService<BaseItemRepository>());
serviceCollection.AddSingleton<IItemQueryHelpers>(sp => sp.GetRequiredService<BaseItemRepository>());
serviceCollection.AddSingleton<IItemPersistenceService, ItemPersistenceService>();
serviceCollection.AddSingleton<INextUpService, NextUpService>();
serviceCollection.AddSingleton<IItemCountService, ItemCountService>();
serviceCollection.AddSingleton<ILinkedChildrenService, LinkedChildrenService>();
serviceCollection.AddSingleton<IPeopleRepository, PeopleRepository>();
serviceCollection.AddSingleton<IChapterRepository, ChapterRepository>();
serviceCollection.AddSingleton<IMediaAttachmentRepository, MediaAttachmentRepository>();
@@ -641,6 +647,7 @@ namespace Emby.Server.Implementations
BaseItem.ConfigurationManager = ConfigurationManager;
BaseItem.FileSystem = Resolve<IFileSystem>();
BaseItem.ItemRepository = Resolve<IItemRepository>();
BaseItem.ItemCountService = Resolve<IItemCountService>();
BaseItem.LibraryManager = Resolve<ILibraryManager>();
BaseItem.LocalizationManager = Resolve<ILocalizationManager>();
BaseItem.Logger = Resolve<ILogger<BaseItem>>();

View File

@@ -272,7 +272,7 @@ namespace Emby.Server.Implementations.Collections
{
var childItem = _libraryManager.GetItemById(guidId);
var child = collection.LinkedChildren.FirstOrDefault(i => (i.ItemId.HasValue && i.ItemId.Value.Equals(guidId)) || (childItem is not null && string.Equals(childItem.Path, i.Path, StringComparison.OrdinalIgnoreCase)));
var child = collection.LinkedChildren.FirstOrDefault(i => i.ItemId.HasValue && i.ItemId.Value.Equals(guidId));
if (child is null)
{
@@ -342,7 +342,7 @@ namespace Emby.Server.Implementations.Collections
// this is kind of a performance hack because only Video has alternate versions that should be in a box set?
if (item is Video video)
{
foreach (var childId in video.GetLocalAlternateVersionIds())
foreach (var childId in _libraryManager.GetLocalAlternateVersionIds(video))
{
if (!results.ContainsKey(childId))
{

View File

@@ -5,10 +5,12 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -35,7 +37,11 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
{
await CleanDeadItems(cancellationToken, progress).ConfigureAwait(false);
var deadItemsProgress = new Progress<double>(val => progress.Report(val * 0.8));
await CleanDeadItems(cancellationToken, deadItemsProgress).ConfigureAwait(false);
var playlistProgress = new Progress<double>(val => progress.Report(80 + (val * 0.2)));
await CleanOrphanedFilePlaylistsAsync(cancellationToken, playlistProgress).ConfigureAwait(false);
}
private async Task CleanDeadItems(CancellationToken cancellationToken, IProgress<double> progress)
@@ -116,4 +122,32 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
progress.Report(100);
}
private async Task CleanOrphanedFilePlaylistsAsync(CancellationToken cancellationToken, IProgress<double> progress)
{
var playlists = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Playlist],
Recursive = true
}).OfType<Playlist>().ToList();
var numComplete = 0;
var numItems = Math.Max(playlists.Count, 1);
foreach (var playlist in playlists)
{
cancellationToken.ThrowIfCancellationRequested();
if (playlist.IsFile && !File.Exists(playlist.Path))
{
_logger.LogInformation("Removing file-based playlist {Name} because source file {Path} no longer exists", playlist.Name, playlist.Path);
_libraryManager.DeleteItem(playlist, new DeleteOptions { DeleteFileLocation = false });
}
numComplete++;
progress.Report((double)numComplete / numItems * 100);
}
progress.Report(100);
}
}

View File

@@ -153,17 +153,68 @@ namespace Emby.Server.Implementations.Dto
private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
/// <inheritdoc />
public IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null)
public IReadOnlyList<BaseItemDto> GetBaseItemDtos(
IReadOnlyList<BaseItem> items,
DtoOptions options,
User? user = null,
BaseItem? owner = null,
bool skipVisibilityCheck = false)
{
var accessibleItems = user is null ? items : items.Where(x => x.IsVisible(user)).ToList();
var accessibleItems = skipVisibilityCheck || user is null ? items : items.Where(x => x.IsVisible(user)).ToList();
var returnItems = new BaseItemDto[accessibleItems.Count];
List<(BaseItem, BaseItemDto)>? programTuples = null;
List<(BaseItemDto, LiveTvChannel)>? channelTuples = null;
// Batch-fetch user data for all items
Dictionary<Guid, UserItemData>? userDataBatch = null;
if (user is not null && options.EnableUserData)
{
userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user);
}
// Pre-compute collection folders once to avoid N+1 queries in CanDelete
List<Folder>? allCollectionFolders = null;
if (user is not null && options.ContainsField(ItemFields.CanDelete))
{
allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList();
}
// Batch-fetch child counts for all folders to avoid N+1 queries
Dictionary<Guid, int>? childCountBatch = null;
if (options.ContainsField(ItemFields.ChildCount))
{
var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
if (folderIds.Count > 0)
{
childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user?.Id);
}
}
// Batch-fetch played/total counts for all folders to avoid N+1 queries
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null;
if (user is not null && options.EnableUserData)
{
var folderIds = accessibleItems.OfType<Folder>()
.Where(f => f.SupportsUserDataFromChildren && (f.SupportsPlayedStatus || options.ContainsField(ItemFields.RecursiveItemCount)))
.Select(f => f.Id).ToList();
if (folderIds.Count > 0)
{
playedCountBatch = _libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
}
}
for (int index = 0; index < accessibleItems.Count; index++)
{
var item = accessibleItems[index];
var dto = GetBaseItemDtoInternal(item, options, user, owner);
var dto = GetBaseItemDtoInternal(
item,
options,
user,
owner,
userDataBatch?.GetValueOrDefault(item.Id),
allCollectionFolders,
childCountBatch,
playedCountBatch);
if (item is LiveTvChannel tvChannel)
{
@@ -197,7 +248,7 @@ namespace Emby.Server.Implementations.Dto
public BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null)
{
var dto = GetBaseItemDtoInternal(item, options, user, owner);
var dto = GetBaseItemDtoInternal(item, options, user, owner, null);
if (item is LiveTvChannel tvChannel)
{
LivetvManager.AddChannelInfo(new[] { (dto, tvChannel) }, options, user);
@@ -215,7 +266,15 @@ namespace Emby.Server.Implementations.Dto
return dto;
}
private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null)
private BaseItemDto GetBaseItemDtoInternal(
BaseItem item,
DtoOptions options,
User? user = null,
BaseItem? owner = null,
UserItemData? userData = null,
List<Folder>? allCollectionFolders = null,
Dictionary<Guid, int>? childCountBatch = null,
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null)
{
var dto = new BaseItemDto
{
@@ -252,7 +311,14 @@ namespace Emby.Server.Implementations.Dto
if (user is not null)
{
AttachUserSpecificInfo(dto, item, user, options);
AttachUserSpecificInfo(
dto,
item,
user,
options,
userData,
childCountBatch,
playedCountBatch);
}
if (item is IHasMediaSources
@@ -274,7 +340,9 @@ namespace Emby.Server.Implementations.Dto
{
dto.CanDelete = user is null
? item.CanDelete()
: item.CanDelete(user);
: allCollectionFolders is not null
? item.CanDelete(user, allCollectionFolders)
: item.CanDelete(user);
}
if (options.ContainsField(ItemFields.CanDownload))
@@ -378,37 +446,7 @@ namespace Emby.Server.Implementations.Dto
return;
}
var query = new InternalItemsQuery(user)
{
Recursive = true,
DtoOptions = new DtoOptions(false) { EnableImages = false },
IncludeItemTypes = relatedItemKinds
};
switch (dto.Type)
{
case BaseItemKind.Genre:
case BaseItemKind.MusicGenre:
query.GenreIds = [dto.Id];
break;
case BaseItemKind.MusicArtist:
query.ArtistIds = [dto.Id];
break;
case BaseItemKind.Person:
query.PersonIds = [dto.Id];
break;
case BaseItemKind.Studio:
query.StudioIds = [dto.Id];
break;
case BaseItemKind.Year
when int.TryParse(dto.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year):
query.Years = [year];
break;
default:
return;
}
var counts = _libraryManager.GetItemCounts(query);
var counts = _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user);
dto.AlbumCount = counts.AlbumCount;
dto.ArtistCount = counts.ArtistCount;
@@ -458,7 +496,14 @@ namespace Emby.Server.Implementations.Dto
/// <summary>
/// Attaches the user specific info.
/// </summary>
private void AttachUserSpecificInfo(BaseItemDto dto, BaseItem item, User user, DtoOptions options)
private void AttachUserSpecificInfo(
BaseItemDto dto,
BaseItem item,
User user,
DtoOptions options,
UserItemData? userData = null,
Dictionary<Guid, int>? childCountBatch = null,
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null)
{
if (item.IsFolder)
{
@@ -466,7 +511,19 @@ namespace Emby.Server.Implementations.Dto
if (options.EnableUserData)
{
dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options);
if (userData is not null)
{
// Use pre-fetched user data
dto.UserData = GetUserItemDataDto(userData, item.Id);
(int Played, int Total)? precomputed = playedCountBatch is not null
&& playedCountBatch.TryGetValue(item.Id, out var counts) ? counts : null;
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options, precomputed);
}
else
{
// Fall back to individual fetch
dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options);
}
}
if (!dto.ChildCount.HasValue && item.SourceType == SourceType.Library)
@@ -485,7 +542,7 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.ChildCount))
{
dto.ChildCount ??= GetChildCount(folder, user);
dto.ChildCount ??= GetChildCount(folder, user, childCountBatch);
}
}
@@ -503,7 +560,17 @@ namespace Emby.Server.Implementations.Dto
{
if (options.EnableUserData)
{
dto.UserData = _userDataRepository.GetUserDataDto(item, user);
if (userData is not null)
{
// Use pre-fetched user data
dto.UserData = GetUserItemDataDto(userData, item.Id);
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
}
else
{
// Fall back to individual fetch
dto.UserData = _userDataRepository.GetUserDataDto(item, user);
}
}
}
@@ -513,7 +580,25 @@ namespace Emby.Server.Implementations.Dto
}
}
private static int GetChildCount(Folder folder, User user)
private static UserItemDataDto GetUserItemDataDto(UserItemData data, Guid itemId)
{
ArgumentNullException.ThrowIfNull(data);
return new UserItemDataDto
{
IsFavorite = data.IsFavorite,
Likes = data.Likes,
PlaybackPositionTicks = data.PlaybackPositionTicks,
PlayCount = data.PlayCount,
Rating = data.Rating,
Played = data.Played,
LastPlayedDate = data.LastPlayedDate,
ItemId = itemId,
Key = data.Key
};
}
private static int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch)
{
// Right now this is too slow to calculate for top level folders on a per-user basis
// Just return something so that apps that are expecting a value won't think the folders are empty
@@ -522,6 +607,13 @@ namespace Emby.Server.Implementations.Dto
return Random.Shared.Next(1, 10);
}
// Use pre-fetched batch data if available
if (childCountBatch is not null && childCountBatch.TryGetValue(folder.Id, out var count))
{
return count;
}
// Fall back to individual query for special cases (Series, Season, etc.)
return folder.GetChildCount(user);
}

View File

@@ -586,6 +586,12 @@ namespace Emby.Server.Implementations.IO
/// <inheritdoc />
public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, string searchPattern, IReadOnlyList<string>? extensions, bool enableCaseSensitiveExtensions, bool recursive = false)
{
if (!Directory.Exists(path))
{
_logger.LogWarning("Directory does not exist: {Path}", path);
return [];
}
var enumerationOptions = GetEnumerationOptions(recursive);
// On linux and macOS the search pattern is case-sensitive

View File

@@ -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)
@@ -1889,6 +2101,44 @@ namespace Emby.Server.Implementations.Library
return video;
}
/// <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)
{
@@ -1993,9 +2243,44 @@ 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);
}
}
}

View File

@@ -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],
};
}
return _cache.GetOrAdd(cacheKey, _ => data);
}
private UserItemData? GetUserDataInternal(Guid userId, Guid itemId, List<string> keys)
{
if (keys.Count == 0)
{
return null;
}
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)
{
var directDataReference = userData.FirstOrDefault(e => e.CustomDataKey == itemId.ToString("N"));
if (directDataReference is not null)
{
return Map(directDataReference);
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 Map(userData.First());
}
return new UserItemData
if (itemsNeedingQuery.Count == 0)
{
Key = keys.Last()!
};
return result;
}
// 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)
{
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)
{
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 };
}
result[item.Id] = userData;
var cacheKey = GetCacheKey(user.InternalId, item.Id);
_cache.AddOrUpdate(cacheKey, userData);
}
}
return result;
}
/// <summary>

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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))
{

View File

@@ -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;
}
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
// Fall back to GetGenre if not found (creates new item if needed)
item ??= _libraryManager.GetGenre(name);
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);
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}

View File

@@ -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;
}
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
// Fall back to GetStudio if not found (creates new item if needed)
item ??= _libraryManager.GetStudio(name);
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);
}
}

View File

@@ -130,8 +130,6 @@
"TaskOptimizeDatabaseDescription": "Compacts database and truncates free space. Running this task after scanning the library or doing other changes that imply database modifications might improve performance.",
"TaskKeyframeExtractor": "Keyframe Extractor",
"TaskKeyframeExtractorDescription": "Extracts keyframes from video files to create more precise HLS playlists. This task may run for a long time.",
"TaskCleanCollectionsAndPlaylists": "Clean up collections and playlists",
"TaskCleanCollectionsAndPlaylistsDescription": "Removes items from collections and playlists that no longer exist.",
"TaskExtractMediaSegments": "Media Segment Scan",
"TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.",
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",

View File

@@ -43,8 +43,9 @@ namespace Emby.Server.Implementations.Playlists
}
query.Recursive = true;
query.IncludeItemTypes = new[] { BaseItemKind.Playlist };
return QueryWithPostFiltering2(query);
query.IncludeItemTypes = [BaseItemKind.Playlist];
return QueryWithPostFiltering(query);
}
public override string GetClientTypeName()

View File

@@ -26,7 +26,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
/// </summary>
public partial class AudioNormalizationTask : IScheduledTask
{
private readonly IItemRepository _itemRepository;
private readonly IItemPersistenceService _persistenceService;
private readonly ILibraryManager _libraryManager;
private readonly IMediaEncoder _mediaEncoder;
private readonly IApplicationPaths _applicationPaths;
@@ -38,21 +38,21 @@ public partial class AudioNormalizationTask : IScheduledTask
/// <summary>
/// Initializes a new instance of the <see cref="AudioNormalizationTask"/> class.
/// </summary>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
/// <param name="persistenceService">Instance of the <see cref="IItemPersistenceService"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{AudioNormalizationTask}"/> interface.</param>
public AudioNormalizationTask(
IItemRepository itemRepository,
IItemPersistenceService persistenceService,
ILibraryManager libraryManager,
IMediaEncoder mediaEncoder,
IApplicationPaths applicationPaths,
ILocalizationManager localizationManager,
ILogger<AudioNormalizationTask> logger)
{
_itemRepository = itemRepository;
_persistenceService = persistenceService;
_libraryManager = libraryManager;
_mediaEncoder = mediaEncoder;
_applicationPaths = applicationPaths;
@@ -138,7 +138,7 @@ public partial class AudioNormalizationTask : IScheduledTask
{
if (toSaveDbItems.Count > 1)
{
_itemRepository.SaveItems(toSaveDbItems, cancellationToken);
_persistenceService.SaveItems(toSaveDbItems, cancellationToken);
toSaveDbItems.Clear();
}
@@ -158,7 +158,7 @@ public partial class AudioNormalizationTask : IScheduledTask
if (toSaveDbItems.Count > 1)
{
_itemRepository.SaveItems(toSaveDbItems, cancellationToken);
_persistenceService.SaveItems(toSaveDbItems, cancellationToken);
toSaveDbItems.Clear();
}
@@ -183,7 +183,7 @@ public partial class AudioNormalizationTask : IScheduledTask
{
if (toSaveDbItems.Count > 1)
{
_itemRepository.SaveItems(toSaveDbItems, cancellationToken);
_persistenceService.SaveItems(toSaveDbItems, cancellationToken);
toSaveDbItems.Clear();
}
@@ -200,7 +200,7 @@ public partial class AudioNormalizationTask : IScheduledTask
if (toSaveDbItems.Count > 1)
{
_itemRepository.SaveItems(toSaveDbItems, cancellationToken);
_persistenceService.SaveItems(toSaveDbItems, cancellationToken);
}
// Update progress

View File

@@ -1,139 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Collections;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
/// <summary>
/// Deletes path references from collections and playlists that no longer exists.
/// </summary>
public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask
{
private readonly ILocalizationManager _localization;
private readonly ICollectionManager _collectionManager;
private readonly IPlaylistManager _playlistManager;
private readonly ILogger<CleanupCollectionAndPlaylistPathsTask> _logger;
private readonly IProviderManager _providerManager;
/// <summary>
/// Initializes a new instance of the <see cref="CleanupCollectionAndPlaylistPathsTask"/> class.
/// </summary>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="collectionManager">Instance of the <see cref="ICollectionManager"/> interface.</param>
/// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
public CleanupCollectionAndPlaylistPathsTask(
ILocalizationManager localization,
ICollectionManager collectionManager,
IPlaylistManager playlistManager,
ILogger<CleanupCollectionAndPlaylistPathsTask> logger,
IProviderManager providerManager)
{
_localization = localization;
_collectionManager = collectionManager;
_playlistManager = playlistManager;
_logger = logger;
_providerManager = providerManager;
}
/// <inheritdoc />
public string Name => _localization.GetLocalizedString("TaskCleanCollectionsAndPlaylists");
/// <inheritdoc />
public string Key => "CleanCollectionsAndPlaylists";
/// <inheritdoc />
public string Description => _localization.GetLocalizedString("TaskCleanCollectionsAndPlaylistsDescription");
/// <inheritdoc />
public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var collectionsFolder = await _collectionManager.GetCollectionsFolder(false).ConfigureAwait(false);
if (collectionsFolder is null)
{
_logger.LogDebug("There is no collections folder to be found");
}
else
{
var collections = collectionsFolder.Children.OfType<BoxSet>().ToArray();
_logger.LogDebug("Found {CollectionLength} boxsets", collections.Length);
for (var index = 0; index < collections.Length; index++)
{
var collection = collections[index];
_logger.LogDebug("Checking boxset {CollectionName}", collection.Name);
await CleanupLinkedChildrenAsync(collection, cancellationToken).ConfigureAwait(false);
progress.Report(50D / collections.Length * (index + 1));
}
}
var playlistsFolder = _playlistManager.GetPlaylistsFolder();
if (playlistsFolder is null)
{
_logger.LogDebug("There is no playlists folder to be found");
return;
}
var playlists = playlistsFolder.Children.OfType<Playlist>().ToArray();
_logger.LogDebug("Found {PlaylistLength} playlists", playlists.Length);
for (var index = 0; index < playlists.Length; index++)
{
var playlist = playlists[index];
_logger.LogDebug("Checking playlist {PlaylistName}", playlist.Name);
await CleanupLinkedChildrenAsync(playlist, cancellationToken).ConfigureAwait(false);
progress.Report(50D / playlists.Length * (index + 1));
}
}
private async Task CleanupLinkedChildrenAsync<T>(T folder, CancellationToken cancellationToken)
where T : Folder
{
List<LinkedChild>? itemsToRemove = null;
foreach (var linkedChild in folder.LinkedChildren)
{
var path = linkedChild.Path;
if (!File.Exists(path) && !Directory.Exists(path))
{
_logger.LogInformation("Item in {FolderName} cannot be found at {ItemPath}", folder.Name, path);
(itemsToRemove ??= new List<LinkedChild>()).Add(linkedChild);
}
}
if (itemsToRemove is not null)
{
_logger.LogDebug("Updating {FolderName}", folder.Name);
folder.LinkedChildren = folder.LinkedChildren.Except(itemsToRemove).ToArray();
await _providerManager.SaveMetadataAsync(folder, ItemUpdateType.MetadataEdit).ConfigureAwait(false);
await folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
yield return new TaskTriggerInfo
{
Type = TaskTriggerInfoType.StartupTrigger,
};
}
}

View File

@@ -1828,7 +1828,6 @@ namespace Emby.Server.Implementations.Session
fields.Remove(ItemFields.Settings);
fields.Remove(ItemFields.SortName);
fields.Remove(ItemFields.Tags);
fields.Remove(ItemFields.ExtraIds);
dtoOptions.Fields = fields.ToArray();

View File

@@ -47,7 +47,7 @@ namespace Emby.Server.Implementations.TV
if (!string.IsNullOrEmpty(presentationUniqueKey))
{
return GetResult(GetNextUpEpisodes(query, user, new[] { presentationUniqueKey }, options), query);
return GetNextUpBatched(query, user, [presentationUniqueKey], options);
}
BaseItem[] parents;
@@ -58,11 +58,11 @@ namespace Emby.Server.Implementations.TV
if (parent is not null)
{
parents = new[] { parent };
parents = [parent];
}
else
{
parents = Array.Empty<BaseItem>();
parents = [];
}
}
else
@@ -93,7 +93,7 @@ namespace Emby.Server.Implementations.TV
if (!string.IsNullOrEmpty(presentationUniqueKey))
{
return GetResult(GetNextUpEpisodes(request, user, [presentationUniqueKey], options), request);
return GetNextUpBatched(request, user, [presentationUniqueKey], options);
}
if (limit.HasValue)
@@ -103,25 +103,138 @@ namespace Emby.Server.Implementations.TV
var nextUpSeriesKeys = _libraryManager.GetNextUpSeriesKeys(new InternalItemsQuery(user) { Limit = limit }, parentsFolders, request.NextUpDateCutoff);
var episodes = GetNextUpEpisodes(request, user, nextUpSeriesKeys, options);
return GetResult(episodes, request);
return GetNextUpBatched(request, user, nextUpSeriesKeys, options);
}
private IEnumerable<Episode> GetNextUpEpisodes(NextUpQuery request, User user, IReadOnlyList<string> seriesKeys, DtoOptions dtoOptions)
private QueryResult<BaseItem> GetNextUpBatched(NextUpQuery request, User user, IReadOnlyList<string> seriesKeys, DtoOptions dtoOptions)
{
var allNextUp = seriesKeys.Select(i => GetNextUp(i, user, dtoOptions, request.EnableResumable, false));
if (request.EnableRewatching)
if (seriesKeys.Count == 0)
{
allNextUp = allNextUp
.Concat(seriesKeys.Select(i => GetNextUp(i, user, dtoOptions, false, true)))
.OrderByDescending(i => i.LastWatchedDate);
return new QueryResult<BaseItem>();
}
return allNextUp
.Select(i => i.GetEpisodeFunction())
.Where(i => i is not null)!;
var includeSpecials = _configurationManager.Configuration.DisplaySpecialsWithinSeasons;
var includeRewatching = request.EnableRewatching;
var query = new InternalItemsQuery(user)
{
DtoOptions = dtoOptions
};
var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatching);
var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>();
foreach (var seriesKey in seriesKeys)
{
if (!batchResult.TryGetValue(seriesKey, out var result))
{
continue;
}
var nextEpisode = DetermineNextEpisode(result, user, includeSpecials, request.EnableResumable, false);
if (nextEpisode is not null)
{
DateTime lastWatchedDate = DateTime.MinValue;
if (result.LastWatched is not null)
{
var userData = _userDataManager.GetUserData(user, result.LastWatched);
lastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
}
nextUpList.Add((lastWatchedDate, nextEpisode));
}
if (includeRewatching)
{
var nextPlayedEpisode = DetermineNextEpisodeForRewatching(result, user, includeSpecials);
if (nextPlayedEpisode is not null)
{
DateTime rewatchLastWatchedDate = DateTime.MinValue;
if (result.LastWatchedForRewatching is not null)
{
var userData = _userDataManager.GetUserData(user, result.LastWatchedForRewatching);
rewatchLastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
}
nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode));
}
}
}
var sortedEpisodes = nextUpList
.OrderByDescending(x => x.LastWatchedDate)
.Select(x => (BaseItem)x.Episode);
return GetResult(sortedEpisodes, request);
}
private Episode? DetermineNextEpisode(
MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result,
User user,
bool includeSpecials,
bool includeResumable,
bool includePlayed)
{
var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode;
var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode;
if (includeSpecials && result.Specials?.Count > 0)
{
var consideredEpisodes = result.Specials
.Cast<Episode>()
.Where(episode => episode.AirsBeforeSeasonNumber is not null || episode.AirsAfterSeasonNumber is not null)
.ToList();
if (lastWatchedEpisode is not null)
{
consideredEpisodes.Add(lastWatchedEpisode);
}
if (nextEpisode is not null)
{
consideredEpisodes.Add(nextEpisode);
}
if (consideredEpisodes.Count > 0)
{
var sortedEpisodes = _libraryManager.Sort(consideredEpisodes, user, [(ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending)])
.Cast<Episode>();
if (lastWatchedEpisode is not null)
{
sortedEpisodes = sortedEpisodes.SkipWhile(episode => !episode.Id.Equals(lastWatchedEpisode.Id)).Skip(1);
}
if (!includePlayed)
{
sortedEpisodes = sortedEpisodes.Where(episode => _userDataManager.GetUserData(user, episode) is not { Played: true });
}
nextEpisode = sortedEpisodes.FirstOrDefault();
}
}
if (nextEpisode is not null && !includeResumable)
{
var userData = _userDataManager.GetUserData(user, nextEpisode);
if (userData?.PlaybackPositionTicks > 0)
{
return null;
}
}
return nextEpisode;
}
private Episode? DetermineNextEpisodeForRewatching(
MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result,
User user,
bool includeSpecials)
{
return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true);
}
private static string GetUniqueSeriesKey(Series series)
@@ -129,127 +242,6 @@ namespace Emby.Server.Implementations.TV
return series.GetPresentationUniqueKey();
}
/// <summary>
/// Gets the next up.
/// </summary>
/// <returns>Task{Episode}.</returns>
private (DateTime LastWatchedDate, Func<Episode?> GetEpisodeFunction) GetNextUp(string seriesKey, User user, DtoOptions dtoOptions, bool includeResumable, bool includePlayed)
{
var lastQuery = new InternalItemsQuery(user)
{
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
IncludeItemTypes = [BaseItemKind.Episode],
IsPlayed = true,
Limit = 1,
ParentIndexNumberNotEquals = 0,
DtoOptions = new DtoOptions
{
Fields = [ItemFields.SortName],
EnableImages = false
}
};
// If including played results, sort first by date played and then by season and episode numbers
lastQuery.OrderBy = includePlayed
? new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Descending) }
: new[] { (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Descending) };
var lastWatchedEpisode = _libraryManager.GetItemList(lastQuery).Cast<Episode>().FirstOrDefault();
Episode? GetEpisode()
{
var nextQuery = new InternalItemsQuery(user)
{
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
IncludeItemTypes = [BaseItemKind.Episode],
OrderBy = [(ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending)],
Limit = 1,
IsPlayed = includePlayed,
IsVirtualItem = false,
ParentIndexNumberNotEquals = 0,
DtoOptions = dtoOptions
};
// Locate the next up episode based on the last watched episode's season and episode number
var lastWatchedParentIndexNumber = lastWatchedEpisode?.ParentIndexNumber;
var lastWatchedIndexNumber = lastWatchedEpisode?.IndexNumberEnd ?? lastWatchedEpisode?.IndexNumber;
if (lastWatchedParentIndexNumber.HasValue && lastWatchedIndexNumber.HasValue)
{
nextQuery.MinParentAndIndexNumber = (lastWatchedParentIndexNumber.Value, lastWatchedIndexNumber.Value + 1);
}
var nextEpisode = _libraryManager.GetItemList(nextQuery).Cast<Episode>().FirstOrDefault();
if (_configurationManager.Configuration.DisplaySpecialsWithinSeasons)
{
var consideredEpisodes = _libraryManager.GetItemList(new InternalItemsQuery(user)
{
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
ParentIndexNumber = 0,
IncludeItemTypes = [BaseItemKind.Episode],
IsPlayed = includePlayed,
IsVirtualItem = false,
DtoOptions = dtoOptions
})
.Cast<Episode>()
.Where(episode => episode.AirsBeforeSeasonNumber is not null || episode.AirsAfterSeasonNumber is not null)
.ToList();
if (lastWatchedEpisode is not null)
{
// Last watched episode is added, because there could be specials that aired before the last watched episode
consideredEpisodes.Add(lastWatchedEpisode);
}
if (nextEpisode is not null)
{
consideredEpisodes.Add(nextEpisode);
}
var sortedConsideredEpisodes = _libraryManager.Sort(consideredEpisodes, user, [(ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending)])
.Cast<Episode>();
if (lastWatchedEpisode is not null)
{
sortedConsideredEpisodes = sortedConsideredEpisodes.SkipWhile(episode => !episode.Id.Equals(lastWatchedEpisode.Id)).Skip(1);
}
nextEpisode = sortedConsideredEpisodes.FirstOrDefault();
}
if (nextEpisode is not null && !includeResumable)
{
var userData = _userDataManager.GetUserData(user, nextEpisode);
if (userData?.PlaybackPositionTicks > 0)
{
return null;
}
}
return nextEpisode;
}
if (lastWatchedEpisode is not null)
{
var userData = _userDataManager.GetUserData(user, lastWatchedEpisode);
if (userData is null)
{
return (DateTime.MinValue, GetEpisode);
}
var lastWatchedDate = userData.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
return (lastWatchedDate, GetEpisode);
}
// Return the first episode
return (DateTime.MinValue, GetEpisode);
}
private static QueryResult<BaseItem> GetResult(IEnumerable<BaseItem> items, NextUpQuery query)
{
int totalCount = 0;

View File

@@ -60,61 +60,33 @@ public class FilterController : BaseJellyfinApiController
BaseItem? item = null;
if (includeItemTypes.Length != 1
|| !(includeItemTypes[0] == BaseItemKind.BoxSet
|| includeItemTypes[0] == BaseItemKind.Playlist
|| includeItemTypes[0] == BaseItemKind.Trailer
|| !(includeItemTypes[0] == BaseItemKind.Trailer
|| includeItemTypes[0] == BaseItemKind.Program))
{
item = _libraryManager.GetParentItem(parentId, user?.Id);
}
var query = new InternalItemsQuery
{
User = user,
MediaTypes = mediaTypes,
IncludeItemTypes = includeItemTypes,
Recursive = true,
EnableTotalRecordCount = false,
DtoOptions = new DtoOptions
{
Fields = new[] { ItemFields.Genres, ItemFields.Tags },
EnableImages = false,
EnableUserData = false
}
};
if (item is not Folder folder)
{
return new QueryFiltersLegacy();
}
var itemList = folder.GetItemList(query);
return new QueryFiltersLegacy
var query = new InternalItemsQuery(user)
{
Years = itemList.Select(i => i.ProductionYear ?? -1)
.Where(i => i > 0)
.Distinct()
.Order()
.ToArray(),
Genres = itemList.SelectMany(i => i.Genres)
.DistinctNames()
.Order()
.ToArray(),
Tags = itemList
.SelectMany(i => i.Tags)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order()
.ToArray(),
OfficialRatings = itemList
.Select(i => i.OfficialRating)
.Where(i => !string.IsNullOrWhiteSpace(i))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order()
.ToArray()
MediaTypes = mediaTypes,
IncludeItemTypes = includeItemTypes,
Recursive = true,
EnableTotalRecordCount = false,
AncestorIds = [folder.Id],
DtoOptions = new DtoOptions
{
Fields = [],
EnableImages = false,
EnableUserData = false
}
};
return _libraryManager.GetQueryFiltersLegacy(query);
}
/// <summary>
@@ -153,9 +125,7 @@ public class FilterController : BaseJellyfinApiController
BaseItem? parentItem = null;
if (includeItemTypes.Length == 1
&& (includeItemTypes[0] == BaseItemKind.BoxSet
|| includeItemTypes[0] == BaseItemKind.Playlist
|| includeItemTypes[0] == BaseItemKind.Trailer
&& (includeItemTypes[0] == BaseItemKind.Trailer
|| includeItemTypes[0] == BaseItemKind.Program))
{
parentItem = null;

View File

@@ -1698,7 +1698,8 @@ public class ImageController : BaseJellyfinApiController
return await GetImageResult(
options,
cacheDuration,
ImmutableDictionary<string, string>.Empty)
ImmutableDictionary<string, string>.Empty,
tag)
.ConfigureAwait(false);
}
@@ -1913,7 +1914,8 @@ public class ImageController : BaseJellyfinApiController
return await GetImageResult(
options,
cacheDuration,
responseHeaders).ConfigureAwait(false);
responseHeaders,
tag).ConfigureAwait(false);
}
private ImageFormat[] GetOutputFormats(ImageFormat? format)
@@ -1992,18 +1994,13 @@ public class ImageController : BaseJellyfinApiController
private async Task<ActionResult> GetImageResult(
ImageProcessingOptions imageProcessingOptions,
TimeSpan? cacheDuration,
IDictionary<string, string> headers)
IDictionary<string, string> headers,
string? tag)
{
var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(imageProcessingOptions).ConfigureAwait(false);
var disableCaching = Request.Headers[HeaderNames.CacheControl].Contains("no-cache");
var parsingSuccessful = DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader);
// if the parsing of the IfModifiedSince header was not successful, disable caching
if (!parsingSuccessful)
{
// disableCaching = true;
}
var hasTag = !string.IsNullOrEmpty(tag);
foreach (var (key, value) in headers)
{
@@ -2025,7 +2022,8 @@ public class ImageController : BaseJellyfinApiController
{
if (cacheDuration.HasValue)
{
Response.Headers.Append(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds);
// When tag is provided, the URL is effectively immutable - the tag changes when the image changes
Response.Headers.Append(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds + ", immutable");
}
else
{
@@ -2034,10 +2032,27 @@ public class ImageController : BaseJellyfinApiController
Response.Headers.Append(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture));
// if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified
if (!(dateImageModified > ifModifiedSinceHeader) && cacheDuration.HasValue)
// Add ETag header for stronger cache validation when tag is provided
if (hasTag)
{
if (ifModifiedSinceHeader.Add(cacheDuration.Value) < DateTime.UtcNow)
Response.Headers.Append(HeaderNames.ETag, $"\"{tag}\"");
// Check If-None-Match header for ETag-based validation (preferred over If-Modified-Since)
var ifNoneMatch = Request.Headers[HeaderNames.IfNoneMatch].ToString();
if (!string.IsNullOrEmpty(ifNoneMatch)
&& (string.Equals(ifNoneMatch, $"\"{tag}\"", StringComparison.Ordinal)
|| string.Equals(ifNoneMatch, tag, StringComparison.Ordinal)))
{
Response.StatusCode = StatusCodes.Status304NotModified;
return new ContentResult();
}
}
// Check If-Modified-Since header for time-based validation
if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader))
{
// Return 304 if the image has not been modified since the client's cached version
if (dateImageModified <= ifModifiedSinceHeader)
{
Response.StatusCode = StatusCodes.Status304NotModified;
return new ContentResult();

View File

@@ -1,6 +1,7 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
@@ -161,7 +162,7 @@ public class ItemsController : BaseJellyfinApiController
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
[HttpGet("Items")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetItems(
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetItems(
[FromQuery] Guid? userId,
[FromQuery] string? maxOfficialRating,
[FromQuery] bool? hasThemeSong,
@@ -299,6 +300,21 @@ public class ItemsController : BaseJellyfinApiController
recursive = true;
includeItemTypes = new[] { BaseItemKind.Playlist };
}
else if (folder is ICollectionFolder)
{
// When the client doesn't specify recursive/includeItemTypes, force the query
// through the database path where all filters (IsHD, genres, etc.) are applied.
recursive ??= true;
if (includeItemTypes.Length == 0)
{
includeItemTypes = collectionType switch
{
CollectionType.boxsets => [BaseItemKind.BoxSet],
null => [BaseItemKind.Movie, BaseItemKind.Series],
_ => []
};
}
}
if (item is not UserRootFolder
// api keys can always access all folders
@@ -502,7 +518,7 @@ public class ItemsController : BaseJellyfinApiController
return new QueryResult<BaseItemDto>(
startIndex,
result.TotalRecordCount,
_dtoService.GetBaseItemDtos(result.Items, dtoOptions, user));
_dtoService.GetBaseItemDtos(result.Items, dtoOptions, user, skipVisibilityCheck: true));
}
/// <summary>
@@ -598,7 +614,7 @@ public class ItemsController : BaseJellyfinApiController
[Obsolete("Kept for backwards compatibility")]
[ApiExplorerSettings(IgnoreApi = true)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserIdLegacy(
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetItemsByUserIdLegacy(
[FromRoute] Guid userId,
[FromQuery] string? maxOfficialRating,
[FromQuery] bool? hasThemeSong,
@@ -684,7 +700,7 @@ public class ItemsController : BaseJellyfinApiController
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds,
[FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool? enableImages = true)
=> GetItems(
=> await GetItems(
userId,
maxOfficialRating,
hasThemeSong,
@@ -770,7 +786,7 @@ public class ItemsController : BaseJellyfinApiController
studioIds,
genreIds,
enableTotalRecordCount,
enableImages);
enableImages).ConfigureAwait(false);
/// <summary>
/// Gets items based on a query.

View File

@@ -442,19 +442,18 @@ public class LibraryController : BaseJellyfinApiController
? null
: _userManager.GetUserById(userId.Value);
var counts = new ItemCounts
var query = new InternalItemsQuery(user)
{
AlbumCount = GetCount(BaseItemKind.MusicAlbum, user, isFavorite),
EpisodeCount = GetCount(BaseItemKind.Episode, user, isFavorite),
MovieCount = GetCount(BaseItemKind.Movie, user, isFavorite),
SeriesCount = GetCount(BaseItemKind.Series, user, isFavorite),
SongCount = GetCount(BaseItemKind.Audio, user, isFavorite),
MusicVideoCount = GetCount(BaseItemKind.MusicVideo, user, isFavorite),
BoxSetCount = GetCount(BaseItemKind.BoxSet, user, isFavorite),
BookCount = GetCount(BaseItemKind.Book, user, isFavorite)
Recursive = true,
IsVirtualItem = false,
IsFavorite = isFavorite,
DtoOptions = new DtoOptions(false)
{
EnableImages = false
}
};
return counts;
return _libraryManager.GetItemCounts(query);
}
/// <summary>
@@ -923,24 +922,6 @@ public class LibraryController : BaseJellyfinApiController
return result;
}
private int GetCount(BaseItemKind itemKind, User? user, bool? isFavorite)
{
var query = new InternalItemsQuery(user)
{
IncludeItemTypes = new[] { itemKind },
Limit = 0,
Recursive = true,
IsVirtualItem = false,
IsFavorite = isFavorite,
DtoOptions = new DtoOptions(false)
{
EnableImages = false
}
};
return _libraryManager.GetItemsResult(query).TotalRecordCount;
}
private BaseItem? TranslateParentItem(BaseItem item, User user)
{
return item.GetParent() is AggregateFolder

View File

@@ -1,4 +1,5 @@
using System;
using System.Threading.Tasks;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
@@ -119,7 +120,7 @@ public class TrailersController : BaseJellyfinApiController
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetTrailers(
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetTrailers(
[FromQuery] Guid? userId,
[FromQuery] string? maxOfficialRating,
[FromQuery] bool? hasThemeSong,
@@ -207,7 +208,7 @@ public class TrailersController : BaseJellyfinApiController
{
var includeItemTypes = new[] { BaseItemKind.Trailer };
return _itemsController
return await _itemsController
.GetItems(
userId,
maxOfficialRating,
@@ -294,6 +295,6 @@ public class TrailersController : BaseJellyfinApiController
studioIds,
genreIds,
enableTotalRecordCount,
enableImages);
enableImages).ConfigureAwait(false);
}
}

View File

@@ -564,25 +564,35 @@ public class UserLibraryController : BaseJellyfinApiController
},
dtoOptions);
var dtos = list.Select(i =>
var resolvedItems = new BaseItem[list.Count];
var childCounts = new int[list.Count];
for (int i = 0; i < list.Count; i++)
{
var item = i.Item2[0];
var tuple = list[i];
var item = tuple.Item2[0];
var childCount = 0;
if (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum || i.Item1 is Series ))
if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum || tuple.Item1 is Series))
{
item = i.Item1;
childCount = i.Item2.Count;
item = tuple.Item1;
childCount = tuple.Item2.Count;
}
var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user);
resolvedItems[i] = item;
childCounts[i] = childCount;
}
dto.ChildCount = childCount;
// Fetch DTOs without visibility check since we've already done that in GetLatestItems and restore child counts afterwards
var dtos = _dtoService.GetBaseItemDtos(resolvedItems, dtoOptions, user, skipVisibilityCheck: true);
for (int i = 0; i < dtos.Count; i++)
{
if (childCounts[i] > 0)
{
dtos[i].ChildCount = childCounts[i];
}
}
return dto;
});
return Ok(dtos);
return Ok(dtos.AsEnumerable());
}
/// <summary>
@@ -637,13 +647,13 @@ public class UserLibraryController : BaseJellyfinApiController
var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
var performFullRefresh = !hasMetadata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3;
if (!hasMetadata)
if (performFullRefresh)
{
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ImageRefreshMode = MetadataRefreshMode.FullRefresh,
ForceSave = performFullRefresh
ForceSave = true
};
await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false);

View File

@@ -148,9 +148,9 @@ public class VideosController : BaseJellyfinApiController
return NotFound();
}
if (item.LinkedAlternateVersions.Length == 0)
if (item.LinkedAlternateVersions.Length == 0 && item.PrimaryVersionId.HasValue)
{
item = _libraryManager.GetItemById<Video>(Guid.Parse(item.PrimaryVersionId));
item = _libraryManager.GetItemById<Video>(item.PrimaryVersionId.Value);
}
if (item is null)
@@ -158,7 +158,7 @@ public class VideosController : BaseJellyfinApiController
return NotFound();
}
foreach (var link in item.GetLinkedAlternateVersions())
foreach (var link in _libraryManager.GetLinkedAlternateVersions(item))
{
link.SetPrimaryVersionId(null);
link.LinkedAlternateVersions = Array.Empty<LinkedChild>();
@@ -198,7 +198,7 @@ public class VideosController : BaseJellyfinApiController
return BadRequest("Please supply at least two videos to merge.");
}
var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId));
var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && !i.PrimaryVersionId.HasValue);
if (primaryVersion is null)
{
primaryVersion = items
@@ -219,22 +219,25 @@ public class VideosController : BaseJellyfinApiController
foreach (var item in items.Where(i => !i.Id.Equals(primaryVersion.Id)))
{
item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture));
item.SetPrimaryVersionId(primaryVersion.Id);
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase)))
// Re-route any playlist/collection references from this item to the primary
await _libraryManager.RerouteLinkedChildReferencesAsync(item.Id, primaryVersion.Id).ConfigureAwait(false);
if (!alternateVersionsOfPrimary.Any(i => i.ItemId.HasValue && i.ItemId.Value.Equals(item.Id)))
{
alternateVersionsOfPrimary.Add(new LinkedChild
{
Path = item.Path,
ItemId = item.Id
ItemId = item.Id,
Type = LinkedChildType.LinkedAlternateVersion
});
}
foreach (var linkedItem in item.LinkedAlternateVersions)
{
if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase)))
if (linkedItem.ItemId.HasValue && !alternateVersionsOfPrimary.Any(i => i.ItemId.HasValue && i.ItemId.Value.Equals(linkedItem.ItemId.Value)))
{
alternateVersionsOfPrimary.Add(linkedItem);
}

View File

@@ -0,0 +1,489 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging;
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
using BaseItemEntity = Jellyfin.Database.Implementations.Entities.BaseItemEntity;
namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// Handles mapping between BaseItemEntity (database) and BaseItemDto (domain) objects.
/// </summary>
internal static class BaseItemMapper
{
/// <summary>
/// This holds all the types in the running assemblies
/// so that we can de-serialize properly when we don't have strong types.
/// </summary>
private static readonly ConcurrentDictionary<string, Type?> _typeMap = new ConcurrentDictionary<string, Type?>();
/// <summary>
/// Maps a Entity to the DTO.
/// </summary>
/// <param name="entity">The entity.</param>
/// <param name="dto">The dto base instance.</param>
/// <param name="appHost">The Application server Host.</param>
/// <returns>The dto to map.</returns>
public static BaseItemDto Map(BaseItemEntity entity, BaseItemDto dto, IServerApplicationHost? appHost)
{
dto.Id = entity.Id;
dto.ParentId = entity.ParentId.GetValueOrDefault();
dto.Path = appHost?.ExpandVirtualPath(entity.Path) ?? entity.Path;
dto.EndDate = entity.EndDate;
dto.CommunityRating = entity.CommunityRating;
dto.CustomRating = entity.CustomRating;
dto.IndexNumber = entity.IndexNumber;
dto.IsLocked = entity.IsLocked;
dto.Name = entity.Name;
dto.OfficialRating = entity.OfficialRating;
dto.Overview = entity.Overview;
dto.ParentIndexNumber = entity.ParentIndexNumber;
dto.PremiereDate = entity.PremiereDate;
dto.ProductionYear = entity.ProductionYear;
dto.SortName = entity.SortName;
dto.ForcedSortName = entity.ForcedSortName;
dto.RunTimeTicks = entity.RunTimeTicks;
dto.PreferredMetadataLanguage = entity.PreferredMetadataLanguage;
dto.PreferredMetadataCountryCode = entity.PreferredMetadataCountryCode;
dto.IsInMixedFolder = entity.IsInMixedFolder;
dto.InheritedParentalRatingValue = entity.InheritedParentalRatingValue;
dto.InheritedParentalRatingSubValue = entity.InheritedParentalRatingSubValue;
dto.CriticRating = entity.CriticRating;
dto.PresentationUniqueKey = entity.PresentationUniqueKey;
dto.OriginalTitle = entity.OriginalTitle;
dto.Album = entity.Album;
dto.LUFS = entity.LUFS;
dto.NormalizationGain = entity.NormalizationGain;
dto.IsVirtualItem = entity.IsVirtualItem;
dto.ExternalSeriesId = entity.ExternalSeriesId;
dto.Tagline = entity.Tagline;
dto.TotalBitrate = entity.TotalBitrate;
dto.ExternalId = entity.ExternalId;
dto.Size = entity.Size;
dto.Genres = string.IsNullOrWhiteSpace(entity.Genres) ? [] : entity.Genres.Split('|');
dto.DateCreated = entity.DateCreated ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
dto.DateModified = entity.DateModified ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
dto.ChannelId = entity.ChannelId ?? Guid.Empty;
dto.DateLastRefreshed = entity.DateLastRefreshed ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
dto.DateLastSaved = entity.DateLastSaved ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
dto.OwnerId = entity.OwnerId ?? Guid.Empty;
dto.Width = entity.Width.GetValueOrDefault();
dto.Height = entity.Height.GetValueOrDefault();
dto.UserData = entity.UserData;
if (entity.Provider is not null)
{
dto.ProviderIds = entity.Provider.ToDictionary(e => e.ProviderId, e => e.ProviderValue);
}
if (entity.ExtraType is not null)
{
dto.ExtraType = (ExtraType)entity.ExtraType;
}
if (entity.LockedFields is not null)
{
dto.LockedFields = entity.LockedFields?.Select(e => (MetadataField)e.Id).ToArray() ?? [];
}
if (entity.Audio is not null)
{
dto.Audio = (ProgramAudio)entity.Audio;
}
dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
dto.Studios = entity.Studios?.Split('|') ?? [];
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
if (dto is IHasProgramAttributes hasProgramAttributes)
{
hasProgramAttributes.IsMovie = entity.IsMovie;
hasProgramAttributes.IsSeries = entity.IsSeries;
hasProgramAttributes.EpisodeTitle = entity.EpisodeTitle;
hasProgramAttributes.IsRepeat = entity.IsRepeat;
}
if (dto is LiveTvChannel liveTvChannel)
{
liveTvChannel.ServiceName = entity.ExternalServiceId;
}
if (dto is Trailer trailer)
{
trailer.TrailerTypes = entity.TrailerTypes?.Select(e => (TrailerType)e.Id).ToArray() ?? [];
}
if (dto is Video video)
{
video.PrimaryVersionId = entity.PrimaryVersionId;
}
if (dto is IHasSeries hasSeriesName)
{
hasSeriesName.SeriesName = entity.SeriesName;
hasSeriesName.SeriesId = entity.SeriesId.GetValueOrDefault();
hasSeriesName.SeriesPresentationUniqueKey = entity.SeriesPresentationUniqueKey;
}
if (dto is Episode episode)
{
episode.SeasonName = entity.SeasonName;
episode.SeasonId = entity.SeasonId.GetValueOrDefault();
}
if (dto is IHasArtist hasArtists)
{
hasArtists.Artists = entity.Artists?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
}
if (dto is IHasAlbumArtist hasAlbumArtists)
{
hasAlbumArtists.AlbumArtists = entity.AlbumArtists?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
}
if (dto is LiveTvProgram program)
{
program.ShowId = entity.ShowId;
}
if (entity.Images is not null)
{
dto.ImageInfos = entity.Images.Select(e => MapImageFromEntity(e, appHost)).ToArray();
}
if (dto is IHasStartDate hasStartDate)
{
hasStartDate.StartDate = entity.StartDate.GetValueOrDefault();
}
// Fields that are present in the DB but are never actually used
// dto.UnratedType = entity.UnratedType;
// dto.TopParentId = entity.TopParentId;
// dto.CleanName = entity.CleanName;
// dto.UserDataKey = entity.UserDataKey;
if (dto is Folder folder)
{
folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
if (entity.LinkedChildEntities is not null && entity.LinkedChildEntities.Count > 0)
{
folder.LinkedChildren = entity.LinkedChildEntities
.OrderBy(e => e.SortOrder)
.Select(e => new LinkedChild
{
ItemId = e.ChildId,
Type = (MediaBrowser.Controller.Entities.LinkedChildType)e.ChildType
})
.ToArray();
}
}
return dto;
}
/// <summary>
/// Maps a DTO to a database entity.
/// </summary>
/// <param name="dto">The DTO.</param>
/// <param name="appHost">The application host for path resolution.</param>
/// <returns>The database entity.</returns>
public static BaseItemEntity Map(BaseItemDto dto, IServerApplicationHost appHost)
{
var dtoType = dto.GetType();
var entity = new BaseItemEntity()
{
Type = dtoType.ToString(),
Id = dto.Id
};
if (TypeRequiresDeserialization(dtoType))
{
entity.Data = JsonSerializer.Serialize(dto, dtoType, JsonDefaults.Options);
}
entity.ParentId = !dto.ParentId.IsEmpty() ? dto.ParentId : null;
entity.Path = GetPathToSave(dto.Path, appHost);
entity.EndDate = dto.EndDate;
entity.CommunityRating = dto.CommunityRating;
entity.CustomRating = dto.CustomRating;
entity.IndexNumber = dto.IndexNumber;
entity.IsLocked = dto.IsLocked;
entity.Name = dto.Name;
entity.CleanName = dto.Name.GetCleanValue();
entity.OfficialRating = dto.OfficialRating;
entity.Overview = dto.Overview;
entity.ParentIndexNumber = dto.ParentIndexNumber;
entity.PremiereDate = dto.PremiereDate;
entity.ProductionYear = dto.ProductionYear;
entity.SortName = dto.SortName;
entity.ForcedSortName = dto.ForcedSortName;
entity.RunTimeTicks = dto.RunTimeTicks;
entity.PreferredMetadataLanguage = dto.PreferredMetadataLanguage;
entity.PreferredMetadataCountryCode = dto.PreferredMetadataCountryCode;
entity.IsInMixedFolder = dto.IsInMixedFolder;
entity.InheritedParentalRatingValue = dto.InheritedParentalRatingValue;
entity.InheritedParentalRatingSubValue = dto.InheritedParentalRatingSubValue;
entity.CriticRating = dto.CriticRating;
entity.PresentationUniqueKey = dto.PresentationUniqueKey;
entity.OriginalTitle = dto.OriginalTitle;
entity.Album = dto.Album;
entity.LUFS = dto.LUFS;
entity.NormalizationGain = dto.NormalizationGain;
entity.IsVirtualItem = dto.IsVirtualItem;
entity.ExternalSeriesId = dto.ExternalSeriesId;
entity.Tagline = dto.Tagline;
entity.TotalBitrate = dto.TotalBitrate;
entity.ExternalId = dto.ExternalId;
entity.Size = dto.Size;
entity.Genres = string.Join('|', dto.Genres.Distinct(StringComparer.OrdinalIgnoreCase));
entity.DateCreated = dto.DateCreated == DateTime.MinValue ? null : dto.DateCreated;
entity.DateModified = dto.DateModified == DateTime.MinValue ? null : dto.DateModified;
entity.ChannelId = dto.ChannelId;
entity.DateLastRefreshed = dto.DateLastRefreshed == DateTime.MinValue ? null : dto.DateLastRefreshed;
entity.DateLastSaved = dto.DateLastSaved == DateTime.MinValue ? null : dto.DateLastSaved;
entity.OwnerId = dto.OwnerId == Guid.Empty ? null : dto.OwnerId;
entity.Width = dto.Width;
entity.Height = dto.Height;
entity.Provider = dto.ProviderIds.Select(e => new BaseItemProvider()
{
Item = entity,
ProviderId = e.Key,
ProviderValue = e.Value
}).ToList();
if (dto.Audio.HasValue)
{
entity.Audio = (ProgramAudioEntity)dto.Audio;
}
if (dto.ExtraType.HasValue)
{
entity.ExtraType = (BaseItemExtraType)dto.ExtraType;
}
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p)).Distinct(StringComparer.OrdinalIgnoreCase)) : null;
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios.Distinct(StringComparer.OrdinalIgnoreCase)) : null;
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags.Distinct(StringComparer.OrdinalIgnoreCase)) : null;
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
.Select(e => new BaseItemMetadataField()
{
Id = (int)e,
Item = entity,
ItemId = entity.Id
})
.ToArray() : null;
if (dto is IHasProgramAttributes hasProgramAttributes)
{
entity.IsMovie = hasProgramAttributes.IsMovie;
entity.IsSeries = hasProgramAttributes.IsSeries;
entity.EpisodeTitle = hasProgramAttributes.EpisodeTitle;
entity.IsRepeat = hasProgramAttributes.IsRepeat;
}
if (dto is LiveTvChannel liveTvChannel)
{
entity.ExternalServiceId = liveTvChannel.ServiceName;
}
if (dto is Video video)
{
entity.PrimaryVersionId = video.PrimaryVersionId;
}
if (dto is IHasSeries hasSeriesName)
{
entity.SeriesName = hasSeriesName.SeriesName;
entity.SeriesId = hasSeriesName.SeriesId;
entity.SeriesPresentationUniqueKey = hasSeriesName.SeriesPresentationUniqueKey;
}
if (dto is Episode episode)
{
entity.SeasonName = episode.SeasonName;
entity.SeasonId = episode.SeasonId;
}
if (dto is IHasArtist hasArtists)
{
entity.Artists = hasArtists.Artists is not null ? string.Join('|', hasArtists.Artists.Distinct(StringComparer.OrdinalIgnoreCase)) : null;
}
if (dto is IHasAlbumArtist hasAlbumArtists)
{
entity.AlbumArtists = hasAlbumArtists.AlbumArtists is not null ? string.Join('|', hasAlbumArtists.AlbumArtists.Distinct(StringComparer.OrdinalIgnoreCase)) : null;
}
if (dto is LiveTvProgram program)
{
entity.ShowId = program.ShowId;
}
if (dto.ImageInfos is not null)
{
entity.Images = dto.ImageInfos.Select(f => MapImageToEntity(dto.Id, f)).ToArray();
}
if (dto is Trailer trailer)
{
entity.TrailerTypes = trailer.TrailerTypes?.Select(e => new BaseItemTrailerType()
{
Id = (int)e,
Item = entity,
ItemId = entity.Id
}).ToArray() ?? [];
}
entity.MediaType = dto.MediaType.ToString();
if (dto is IHasStartDate hasStartDate)
{
entity.StartDate = hasStartDate.StartDate;
}
entity.UnratedType = dto.GetBlockUnratedType().ToString();
// Fields that are present in the DB but are never actually used
// dto.UserDataKey = entity.UserDataKey;
if (dto is Folder folder)
{
entity.DateLastMediaAdded = folder.DateLastMediaAdded == DateTime.MinValue ? null : folder.DateLastMediaAdded;
entity.IsFolder = folder.IsFolder;
}
return entity;
}
/// <summary>
/// Maps a database image entity to a domain image info.
/// </summary>
/// <param name="e">The database image entity.</param>
/// <param name="appHost">The application host.</param>
/// <returns>The mapped image info.</returns>
public static ItemImageInfo MapImageFromEntity(BaseItemImageInfo e, IServerApplicationHost? appHost)
{
return new ItemImageInfo()
{
Path = appHost?.ExpandVirtualPath(e.Path) ?? e.Path,
BlurHash = e.Blurhash is null ? null : Encoding.UTF8.GetString(e.Blurhash),
DateModified = e.DateModified ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc),
Height = e.Height,
Width = e.Width,
Type = (ImageType)e.ImageType
};
}
/// <summary>
/// Maps a domain image info to a database image entity.
/// </summary>
/// <param name="baseItemId">The parent item ID.</param>
/// <param name="e">The image info to map.</param>
/// <returns>The mapped database entity.</returns>
public static BaseItemImageInfo MapImageToEntity(Guid baseItemId, ItemImageInfo e)
{
return new BaseItemImageInfo()
{
ItemId = baseItemId,
Id = Guid.NewGuid(),
Path = e.Path,
Blurhash = e.BlurHash is null ? null : Encoding.UTF8.GetBytes(e.BlurHash),
DateModified = e.DateModified,
Height = e.Height,
Width = e.Width,
ImageType = (ImageInfoImageType)e.Type,
Item = null!
};
}
/// <summary>
/// Gets the type from a type name string.
/// </summary>
/// <param name="typeName">The type name.</param>
/// <returns>The resolved type, or null.</returns>
public static Type? GetType(string typeName)
{
ArgumentException.ThrowIfNullOrEmpty(typeName);
return _typeMap.GetOrAdd(typeName, k => AppDomain.CurrentDomain.GetAssemblies()
.Select(a => a.GetType(k))
.FirstOrDefault(t => t is not null));
}
/// <summary>
/// Checks whether a type requires JSON deserialization.
/// </summary>
/// <param name="type">The type to check.</param>
/// <returns>True if the type requires deserialization.</returns>
public static bool TypeRequiresDeserialization(Type type)
{
return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null;
}
/// <summary>
/// Deserializes a BaseItemEntity and sets all properties.
/// </summary>
/// <param name="baseItemEntity">The DB entity.</param>
/// <param name="logger">Logger.</param>
/// <param name="appHost">The application server Host.</param>
/// <param name="skipDeserialization">If only mapping should be processed.</param>
/// <returns>A mapped BaseItem, or null if the item type is unknown.</returns>
public static BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
{
var type = GetType(baseItemEntity.Type);
if (type is null)
{
logger.LogWarning(
"Skipping item {ItemId} with unknown type '{ItemType}'. This may indicate a removed plugin or database corruption.",
baseItemEntity.Id,
baseItemEntity.Type);
return null;
}
BaseItemDto? dto = null;
if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization)
{
try
{
dto = JsonSerializer.Deserialize(baseItemEntity.Data, type, JsonDefaults.Options) as BaseItemDto;
}
catch (JsonException ex)
{
logger.LogError(ex, "Error deserializing item with JSON: {Data}", baseItemEntity.Data);
}
}
if (dto is null)
{
dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialize unknown type.");
}
return Map(baseItemEntity, dto, appHost);
}
private static string? GetPathToSave(string path, IServerApplicationHost appHost)
{
if (path is null)
{
return null;
}
return appHost.ReverseVirtualPath(path);
}
}

View File

@@ -0,0 +1,290 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Querying;
using Microsoft.EntityFrameworkCore;
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
namespace Jellyfin.Server.Implementations.Item;
public sealed partial class BaseItemRepository
{
/// <inheritdoc />
public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAllArtists(InternalItemsQuery filter)
{
return GetItemValues(filter, _getAllArtistsValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]);
}
/// <inheritdoc />
public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetArtists(InternalItemsQuery filter)
{
return GetItemValues(filter, _getArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]);
}
/// <inheritdoc />
public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAlbumArtists(InternalItemsQuery filter)
{
return GetItemValues(filter, _getAlbumArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]);
}
/// <inheritdoc />
public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetStudios(InternalItemsQuery filter)
{
return GetItemValues(filter, _getStudiosValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Studio]);
}
/// <inheritdoc />
public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetGenres(InternalItemsQuery filter)
{
return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre]);
}
/// <inheritdoc />
public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetMusicGenres(InternalItemsQuery filter)
{
return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicGenre]);
}
/// <inheritdoc />
public IReadOnlyList<string> GetStudioNames()
{
return GetItemValueNames(_getStudiosValueTypes, [], []);
}
/// <inheritdoc />
public IReadOnlyList<string> GetAllArtistNames()
{
return GetItemValueNames(_getAllArtistsValueTypes, [], []);
}
/// <inheritdoc />
public IReadOnlyList<string> GetMusicGenreNames()
{
return GetItemValueNames(
_getGenreValueTypes,
_itemTypeLookup.MusicGenreTypes,
[]);
}
/// <inheritdoc />
public IReadOnlyList<string> GetGenreNames()
{
return GetItemValueNames(
_getGenreValueTypes,
[],
_itemTypeLookup.MusicGenreTypes);
}
private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes)
{
using var context = _dbProvider.CreateDbContext();
var query = context.ItemValuesMap
.AsNoTracking()
.Where(e => itemValueTypes.Any(w => w == e.ItemValue.Type));
if (withItemTypes.Count > 0)
{
query = query.Where(e => withItemTypes.Contains(e.Item.Type));
}
if (excludeItemTypes.Count > 0)
{
query = query.Where(e => !excludeItemTypes.Contains(e.Item.Type));
}
return query.Select(e => e.ItemValue)
.GroupBy(e => e.CleanValue)
.Select(g => g.Min(v => v.Value)!)
.ToArray();
}
private QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList<ItemValueType> itemValueTypes, string returnType)
{
ArgumentNullException.ThrowIfNull(filter);
if (!filter.Limit.HasValue)
{
filter.EnableTotalRecordCount = false;
}
using var context = _dbProvider.CreateDbContext();
var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User)
{
ExcludeItemTypes = filter.ExcludeItemTypes,
IncludeItemTypes = filter.IncludeItemTypes,
MediaTypes = filter.MediaTypes,
AncestorIds = filter.AncestorIds,
ItemIds = filter.ItemIds,
TopParentIds = filter.TopParentIds,
ParentId = filter.ParentId,
IsAiring = filter.IsAiring,
IsMovie = filter.IsMovie,
IsSports = filter.IsSports,
IsKids = filter.IsKids,
IsNews = filter.IsNews,
IsSeries = filter.IsSeries
});
// Keep this as an IQueryable sub-select. Materializing to a list would inline one
// bound parameter per CleanValue and hit SQLite's variable cap on libraries with
// high-cardinality value types (e.g. tens of thousands of artists).
var matchingCleanValues = context.ItemValuesMap
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
.Join(
innerQueryFilter,
ivm => ivm.ItemId,
g => g.Id,
(ivm, g) => ivm.ItemValue.CleanValue)
.Distinct();
var innerQuery = PrepareItemQuery(context, filter)
.Where(e => e.Type == returnType)
.Where(e => matchingCleanValues.Contains(e.CleanName!));
var outerQueryFilter = new InternalItemsQuery(filter.User)
{
IsPlayed = filter.IsPlayed,
IsFavorite = filter.IsFavorite,
IsFavoriteOrLiked = filter.IsFavoriteOrLiked,
IsLiked = filter.IsLiked,
IsLocked = filter.IsLocked,
NameLessThan = filter.NameLessThan,
NameStartsWith = filter.NameStartsWith,
NameStartsWithOrGreater = filter.NameStartsWithOrGreater,
Tags = filter.Tags,
OfficialRatings = filter.OfficialRatings,
StudioIds = filter.StudioIds,
GenreIds = filter.GenreIds,
Genres = filter.Genres,
Years = filter.Years,
NameContains = filter.NameContains,
SearchTerm = filter.SearchTerm,
ExcludeItemIds = filter.ExcludeItemIds
};
// Build the master query and collapse rows that share a PresentationUniqueKey
// (e.g. alternate versions) by picking the lowest Id per group.
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter);
var orderedMasterQuery = ApplyOrder(masterQuery, filter, context)
.GroupBy(e => e.PresentationUniqueKey)
.Select(g => g.Min(e => e.Id));
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
if (filter.EnableTotalRecordCount)
{
result.TotalRecordCount = orderedMasterQuery.Count();
}
if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
{
orderedMasterQuery = orderedMasterQuery.Skip(filter.StartIndex.Value);
}
if (filter.Limit.HasValue)
{
orderedMasterQuery = orderedMasterQuery.Take(filter.Limit.Value);
}
var masterIds = orderedMasterQuery.ToList();
var query = ApplyNavigations(
context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => masterIds.Contains(e.Id)),
filter);
query = ApplyOrder(query, filter, context);
if (filter.IncludeItemTypes.Length > 0)
{
var typeSubQuery = new InternalItemsQuery(filter.User)
{
ExcludeItemTypes = filter.ExcludeItemTypes,
IncludeItemTypes = filter.IncludeItemTypes,
MediaTypes = filter.MediaTypes,
AncestorIds = filter.AncestorIds,
ExcludeItemIds = filter.ExcludeItemIds,
ItemIds = filter.ItemIds,
TopParentIds = filter.TopParentIds,
ParentId = filter.ParentId,
IsPlayed = filter.IsPlayed
};
var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery)
.Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type)));
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum];
var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
var itemIds = itemCountQuery.Select(e => e.Id);
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
// Instead, start from ItemValueMaps and join with BaseItems
var countsByCleanName = context.ItemValuesMap
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
.Where(ivm => itemIds.Contains(ivm.ItemId))
.Join(
context.BaseItems,
ivm => ivm.ItemId,
e => e.Id,
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
.GroupBy(x => new { x.CleanName, x.Type })
.Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
.GroupBy(x => x.CleanName)
.ToDictionary(
g => g.Key,
g => new ItemCounts
{
SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count),
EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count),
MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count),
AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count),
ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count),
SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count),
TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count),
});
result.StartIndex = filter.StartIndex ?? 0;
result.Items =
[
.. query
.AsEnumerable()
.Where(e => e is not null)
.Select(e =>
{
var item = DeserializeBaseItem(e, filter.SkipDeserialization);
countsByCleanName.TryGetValue(e.CleanName ?? string.Empty, out var itemCount);
return (item, itemCount);
})
.Where(x => x.item is not null)
.Select(x => (x.item!, x.itemCount))
];
}
else
{
result.StartIndex = filter.StartIndex ?? 0;
result.Items =
[
.. query
.AsEnumerable()
.Where(e => e != null)
.Select(e => DeserializeBaseItem(e, filter.SkipDeserialization))
.Where(item => item != null)
.Select(item => (item!, (ItemCounts?)null))
];
}
return result;
}
}

View File

@@ -0,0 +1,557 @@
#pragma warning disable RS0030 // Do not use banned APIs
#pragma warning disable CA1304 // Specify CultureInfo
#pragma warning disable CA1311 // Specify a culture or use an invariant version
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.EntityFrameworkCore;
using BaseItemEntity = Jellyfin.Database.Implementations.Entities.BaseItemEntity;
namespace Jellyfin.Server.Implementations.Item;
public sealed partial class BaseItemRepository
{
/// <inheritdoc />
public IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter)
{
IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking();
dbQuery = dbQuery.AsSingleQuery();
return dbQuery;
}
private IQueryable<BaseItemEntity> ApplyQueryFilter(IQueryable<BaseItemEntity> dbQuery, JellyfinDbContext context, InternalItemsQuery filter)
{
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery;
}
private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
if (filter.Limit.HasValue || filter.StartIndex.HasValue)
{
var offset = filter.StartIndex ?? 0;
if (offset > 0)
{
dbQuery = dbQuery.Skip(offset);
}
if (filter.Limit.HasValue)
{
dbQuery = dbQuery.Take(filter.Limit.Value);
}
}
return dbQuery;
}
private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
// Collapse duplicates sharing a presentation key (e.g. alternate versions) by picking
// the min Id per group. Keep the grouped ids as an IQueryable sub-select; materializing
// to a List would inline one bound parameter per id and hit SQLite's variable cap.
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
{
var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.Min(x => x.Id));
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
}
else if (enableGroupByPresentationUniqueKey)
{
var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.Min(x => x.Id));
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
}
else if (filter.GroupBySeriesPresentationUniqueKey)
{
var groupedIds = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.Min(x => x.Id));
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
}
else
{
dbQuery = dbQuery.Distinct();
}
if (filter.CollapseBoxSetItems == true)
{
dbQuery = ApplyBoxSetCollapsing(context, dbQuery, filter.CollapseBoxSetItemTypes);
// Name filters run after collapse so BoxSets match by their own name, not a child's.
dbQuery = ApplyNameFilters(dbQuery, filter);
}
dbQuery = ApplyOrder(dbQuery, filter, context);
return dbQuery;
}
private IQueryable<BaseItemEntity> ApplyBoxSetCollapsing(
JellyfinDbContext context,
IQueryable<BaseItemEntity> dbQuery,
BaseItemKind[] collapsibleTypes)
{
var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet];
var currentIds = dbQuery.Select(e => e.Id);
if (collapsibleTypes.Length == 0)
{
// Collapse all item types into box sets
return ApplyBoxSetCollapsingAll(context, currentIds, boxSetTypeName);
}
// Only collapse specific item types, keep others untouched
var collapsibleTypeNames = collapsibleTypes.Select(t => _itemTypeLookup.BaseItemKindNames[t]).ToList();
// Categorize items in currentIds in a single pass to avoid multiple correlated EXISTS over BaseItems.
var categorized = context.BaseItems
.AsNoTracking()
.Where(bi => currentIds.Contains(bi.Id))
.Select(bi => new
{
bi.Id,
IsCollapsible = collapsibleTypeNames.Contains(bi.Type),
IsBoxSet = bi.Type == boxSetTypeName
});
var collapsibleChildIds = categorized.Where(c => c.IsCollapsible).Select(c => c.Id);
// Single JOIN: manual links to BoxSet parents, restricted to currentIds children.
var manualBoxSetLinks = context.LinkedChildren
.Where(lc => lc.ChildType == Database.Implementations.Entities.LinkedChildType.Manual
&& currentIds.Contains(lc.ChildId))
.Join(
context.BaseItems.Where(bs => bs.Type == boxSetTypeName),
lc => lc.ParentId,
bs => bs.Id,
(lc, bs) => new { lc.ChildId, lc.ParentId });
var childrenInBoxSet = manualBoxSetLinks.Select(x => x.ChildId).Distinct();
// Items whose type is NOT collapsible (always kept in results)
var nonCollapsibleIds = categorized.Where(c => !c.IsCollapsible).Select(c => c.Id);
// Collapsible items that are not a BoxSet themselves and not a manual child of any BoxSet
var collapsibleNotInBoxSet = categorized
.Where(c => c.IsCollapsible && !c.IsBoxSet)
.Select(c => c.Id)
.Where(id => !childrenInBoxSet.Contains(id));
// BoxSet IDs containing at least one collapsible child item from currentIds
var boxSetIds = manualBoxSetLinks
.Where(x => collapsibleChildIds.Contains(x.ChildId))
.Select(x => x.ParentId)
.Distinct();
var collapsedIds = nonCollapsibleIds.Union(collapsibleNotInBoxSet).Union(boxSetIds);
return context.BaseItems.AsNoTracking().Where(e => collapsedIds.Contains(e.Id));
}
private static IQueryable<BaseItemEntity> ApplyBoxSetCollapsingAll(
JellyfinDbContext context,
IQueryable<Guid> currentIds,
string boxSetTypeName)
{
// Single JOIN: manual links to BoxSet parents, restricted to currentIds children.
var manualBoxSetLinks = context.LinkedChildren
.Where(lc => lc.ChildType == Database.Implementations.Entities.LinkedChildType.Manual
&& currentIds.Contains(lc.ChildId))
.Join(
context.BaseItems.Where(bs => bs.Type == boxSetTypeName),
lc => lc.ParentId,
bs => bs.Id,
(lc, bs) => new { lc.ChildId, lc.ParentId });
var childrenInBoxSet = manualBoxSetLinks.Select(x => x.ChildId).Distinct();
var boxSetIds = manualBoxSetLinks.Select(x => x.ParentId).Distinct();
// Items in currentIds that are not BoxSets themselves and not a manual child of any BoxSet
var notInBoxSet = context.BaseItems
.AsNoTracking()
.Where(e => currentIds.Contains(e.Id) && e.Type != boxSetTypeName)
.Select(e => e.Id)
.Where(id => !childrenInBoxSet.Contains(id));
var collapsedIds = notInBoxSet.Union(boxSetIds);
return context.BaseItems.AsNoTracking().Where(e => collapsedIds.Contains(e.Id));
}
private static IQueryable<BaseItemEntity> ApplyNameFilters(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
{
var nameStartsWithLower = filter.NameStartsWith.ToLowerInvariant();
dbQuery = dbQuery.Where(e => e.SortName!.ToLower().StartsWith(nameStartsWithLower));
}
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
{
var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant();
dbQuery = dbQuery.Where(e => e.SortName!.ToLower().CompareTo(startsOrGreaterLower) >= 0);
}
if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
{
var lessThanLower = filter.NameLessThan.ToLowerInvariant();
dbQuery = dbQuery.Where(e => e.SortName!.ToLower().CompareTo(lessThanLower) < 0);
}
return dbQuery;
}
/// <inheritdoc />
public IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
{
dbQuery = dbQuery.Include(e => e.TrailerTypes);
}
if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
{
dbQuery = dbQuery.Include(e => e.Provider);
}
if (filter.DtoOptions.ContainsField(ItemFields.Settings))
{
dbQuery = dbQuery.Include(e => e.LockedFields);
}
if (filter.DtoOptions.EnableUserData)
{
dbQuery = dbQuery.Include(e => e.UserData);
}
if (filter.DtoOptions.EnableImages)
{
dbQuery = dbQuery.Include(e => e.Images);
}
// Include LinkedChildEntities for container types and videos that use them
// (BoxSet, Playlist, CollectionFolder for manual linking; Video, Movie for alternate versions).
// When IncludeItemTypes is empty (any type may be returned), always include them to ensure
// LinkedChildren are loaded before items are saved back, preventing accidental deletion.
var linkedChildTypes = new[]
{
BaseItemKind.BoxSet,
BaseItemKind.Playlist,
BaseItemKind.CollectionFolder,
BaseItemKind.Video,
BaseItemKind.Movie
};
if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains))
{
dbQuery = dbQuery.Include(e => e.LinkedChildEntities);
}
if (filter.IncludeExtras)
{
dbQuery = dbQuery.Include(e => e.Extras);
}
return dbQuery;
}
/// <inheritdoc />
public IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
{
var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray();
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
// SeriesDatePlayed requires special handling to avoid correlated subqueries.
// Instead of running a MAX() subquery per-row in ORDER BY, we pre-aggregate
// max played dates per series in one query and left-join it.
if (!hasSearch && orderBy.Any(o => o.OrderBy == ItemSortBy.SeriesDatePlayed))
{
return ApplySeriesDatePlayedOrder(query, filter, context, orderBy);
}
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
if (hasSearch)
{
var relevanceExpression = OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!);
orderedQuery = query.OrderBy(relevanceExpression);
}
if (orderBy.Length > 0)
{
var firstOrdering = orderBy[0];
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
if (orderedQuery is null)
{
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
? query.OrderBy(expression)
: query.OrderByDescending(expression);
}
else
{
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
? orderedQuery.ThenBy(expression)
: orderedQuery.ThenByDescending(expression);
}
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
{
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
? orderedQuery.ThenBy(e => e.Name)
: orderedQuery.ThenByDescending(e => e.Name);
}
foreach (var item in orderBy.Skip(1))
{
expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context);
orderedQuery = item.SortOrder == SortOrder.Ascending
? orderedQuery.ThenBy(expression)
: orderedQuery.ThenByDescending(expression);
}
}
if (orderedQuery is null)
{
return query.OrderBy(e => e.SortName);
}
// Add SortName as final tiebreaker
if (!hasSearch && (orderBy.Length == 0 || orderBy.All(o => o.OrderBy is not ItemSortBy.SortName and not ItemSortBy.Name)))
{
orderedQuery = orderedQuery.ThenBy(e => e.SortName);
}
return orderedQuery;
}
private IQueryable<BaseItemEntity> ApplySeriesDatePlayedOrder(
IQueryable<BaseItemEntity> query,
InternalItemsQuery filter,
JellyfinDbContext context,
(ItemSortBy OrderBy, SortOrder SortOrder)[] orderBy)
{
// Pre-aggregate max played date per series key in ONE query.
// This generates a single: SELECT SeriesPresentationUniqueKey, MAX(LastPlayedDate) ... GROUP BY
// instead of a correlated subquery per outer row.
IQueryable<UserData> userDataQuery = filter.User is not null
? context.UserData.Where(ud => ud.UserId == filter.User.Id && ud.Played)
: context.UserData.Where(ud => ud.Played);
var seriesMaxDates = userDataQuery
.Join(
context.BaseItems,
ud => ud.ItemId,
bi => bi.Id,
(ud, bi) => new { bi.SeriesPresentationUniqueKey, ud.LastPlayedDate })
.Where(x => x.SeriesPresentationUniqueKey != null)
.GroupBy(x => x.SeriesPresentationUniqueKey)
.Select(g => new { SeriesKey = g.Key!, MaxDate = g.Max(x => x.LastPlayedDate) });
var joined = query.LeftJoin(
seriesMaxDates,
e => e.PresentationUniqueKey,
s => s.SeriesKey,
(e, s) => new { Item = e, MaxDate = s != null ? s.MaxDate : (DateTime?)null });
var seriesSort = orderBy.First(o => o.OrderBy == ItemSortBy.SeriesDatePlayed);
return seriesSort.SortOrder == SortOrder.Ascending
? joined.OrderBy(x => x.MaxDate).ThenBy(x => x.Item.SortName).Select(x => x.Item)
: joined.OrderByDescending(x => x.MaxDate).ThenBy(x => x.Item.SortName).Select(x => x.Item);
}
/// <summary>
/// Builds a query for descendants of an ancestor with user access filtering applied.
/// Uses recursive CTE to traverse both hierarchical (AncestorIds) and linked (LinkedChildren) relationships.
/// </summary>
/// <inheritdoc />
public IQueryable<BaseItemEntity> BuildAccessFilteredDescendantsQuery(
JellyfinDbContext context,
InternalItemsQuery filter,
Guid ancestorId)
{
// Use recursive CTE to get all descendants (hierarchical and linked)
var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(context, ancestorId);
var baseQuery = context.BaseItems
.AsNoTracking()
.Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem);
return ApplyAccessFiltering(context, baseQuery, filter);
}
/// <summary>
/// Applies user access filtering to a query.
/// Includes TopParentIds, parental rating, and tag filtering.
/// </summary>
/// <inheritdoc />
public IQueryable<BaseItemEntity> ApplyAccessFiltering(
JellyfinDbContext context,
IQueryable<BaseItemEntity> baseQuery,
InternalItemsQuery filter)
{
// Apply TopParentIds filtering (library folder access)
if (filter.TopParentIds.Length > 0)
{
var topParentIds = filter.TopParentIds;
baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value));
}
// Apply parental rating filtering
if (filter.MaxParentalRating is not null)
{
baseQuery = baseQuery.Where(BuildMaxParentalRatingFilter(context, filter.MaxParentalRating));
}
// Apply block unrated items filtering
if (filter.BlockUnratedItems.Length > 0)
{
var unratedItemTypes = filter.BlockUnratedItems.Select(f => f.ToString()).ToArray();
baseQuery = baseQuery.Where(e =>
e.InheritedParentalRatingValue != null || !unratedItemTypes.Contains(e.UnratedType));
}
// Apply excluded tags filtering (blocked tags).
// Pre-build the blocked-item-id set as a sub-select; then four index-seek Contains checks
// instead of one EXISTS over a 4-way OR predicate that defeats index seeks.
if (filter.ExcludeInheritedTags.Length > 0)
{
var excludedTags = filter.ExcludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
var blockedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
baseQuery = baseQuery.Where(e =>
!blockedTagItemIds.Contains(e.Id)
&& !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value))
&& !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId))
&& !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value)));
}
// Apply included tags filtering (allowed tags - item must have at least one).
if (filter.IncludeInheritedTags.Length > 0)
{
var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
var allowedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
baseQuery = baseQuery.Where(e =>
allowedTagItemIds.Contains(e.Id)
|| (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value))
|| e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
|| (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)));
}
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
if (!filter.IncludeOwnedItems)
{
baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
}
return baseQuery;
}
/// <summary>
/// Builds a filter expression for max parental rating that handles both rated items
/// and unrated BoxSets/Playlists (which check linked children's ratings).
/// </summary>
private static Expression<Func<BaseItemEntity, bool>> BuildMaxParentalRatingFilter(
JellyfinDbContext context,
ParentalRatingScore maxRating)
{
var maxScore = maxRating.Score;
var maxSubScore = maxRating.SubScore ?? 0;
var linkedChildren = context.LinkedChildren;
return e =>
// Item has a rating: check against limit
(e.InheritedParentalRatingValue != null
&& (e.InheritedParentalRatingValue < maxScore
|| (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)))
// Item has no rating
|| (e.InheritedParentalRatingValue == null
&& (
// No linked children (not a BoxSet/Playlist): pass as unrated
!linkedChildren.Any(lc => lc.ParentId == e.Id)
// Has linked children: at least one child must be within limits
|| linkedChildren.Any(lc => lc.ParentId == e.Id
&& (lc.Child!.InheritedParentalRatingValue == null
|| lc.Child.InheritedParentalRatingValue < maxScore
|| (lc.Child.InheritedParentalRatingValue == maxScore
&& (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)))));
}
/// <inheritdoc />
public IQueryable<Guid> GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable<Guid> folderIds, User user)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(folderIds);
ArgumentNullException.ThrowIfNull(user);
var filter = new InternalItemsQuery(user);
var userId = user.Id;
var leafItems = context.BaseItems
.AsNoTracking()
.Where(b => !b.IsFolder && !b.IsVirtualItem);
leafItems = ApplyAccessFiltering(context, leafItems, filter);
var playedLeafItems = leafItems
.Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
var ancestorLeaves = context.AncestorIds
.Where(a => folderIds.Contains(a.ParentItemId))
.Join(
playedLeafItems,
a => a.ItemId,
b => b.Id,
(a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played });
var linkedLeaves = context.LinkedChildren
.Where(lc => folderIds.Contains(lc.ParentId))
.Join(
playedLeafItems,
lc => lc.ChildId,
b => b.Id,
(lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played });
var linkedFolderLeaves = context.LinkedChildren
.Where(lc => folderIds.Contains(lc.ParentId))
.Join(
context.BaseItems.Where(b => b.IsFolder),
lc => lc.ChildId,
b => b.Id,
(lc, b) => new { lc.ParentId, FolderChildId = b.Id })
.Join(
context.AncestorIds,
x => x.FolderChildId,
a => a.ParentItemId,
(x, a) => new { x.ParentId, DescendantId = a.ItemId })
.Join(
playedLeafItems,
x => x.DescendantId,
b => b.Id,
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
return ancestorLeaves
.Union(linkedLeaves)
.Union(linkedFolderLeaves)
.GroupBy(x => x.FolderId)
.Where(g => g.Select(x => x.Id).Distinct().Count() == g.Where(x => x.Played).Select(x => x.Id).Distinct().Count())
.Select(g => g.Key);
}
}

View File

@@ -0,0 +1,539 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.EntityFrameworkCore;
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
namespace Jellyfin.Server.Implementations.Item;
public sealed partial class BaseItemRepository
{
/// <inheritdoc />
public IReadOnlyList<Guid> GetItemIdsList(InternalItemsQuery filter)
{
ArgumentNullException.ThrowIfNull(filter);
PrepareFilterQuery(filter);
using var context = _dbProvider.CreateDbContext();
return ApplyQueryFilter(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, filter).Select(e => e.Id).ToArray();
}
/// <inheritdoc />
public QueryResult<BaseItemDto> GetItems(InternalItemsQuery filter)
{
ArgumentNullException.ThrowIfNull(filter);
if (!filter.EnableTotalRecordCount || (!filter.Limit.HasValue && (filter.StartIndex ?? 0) == 0))
{
var returnList = GetItemList(filter);
return new QueryResult<BaseItemDto>(
filter.StartIndex,
returnList.Count,
returnList);
}
PrepareFilterQuery(filter);
var result = new QueryResult<BaseItemDto>();
using var context = _dbProvider.CreateDbContext();
IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter);
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
if (filter.EnableTotalRecordCount)
{
result.TotalRecordCount = dbQuery.Count();
}
dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
result.Items = dbQuery.AsEnumerable().Where(e => e != null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto != null).ToArray()!;
result.StartIndex = filter.StartIndex ?? 0;
return result;
}
/// <inheritdoc />
public IReadOnlyList<BaseItemDto> GetItemList(InternalItemsQuery filter)
{
ArgumentNullException.ThrowIfNull(filter);
PrepareFilterQuery(filter);
using var context = _dbProvider.CreateDbContext();
IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter);
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
if (hasRandomSort)
{
var orderedIds = dbQuery.AsNoTracking().Select(e => e.Id).ToList();
if (orderedIds.Count == 0)
{
return Array.Empty<BaseItemDto>();
}
var itemsById = ApplyNavigations(context.BaseItems.AsNoTracking().WhereOneOrMany(orderedIds, e => e.Id), filter)
.AsSplitQuery()
.AsEnumerable()
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
.Where(dto => dto != null)
.ToDictionary(i => i!.Id);
return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
}
dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery.AsEnumerable().Where(e => e != null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto != null).ToArray()!;
}
/// <inheritdoc/>
public IReadOnlyList<BaseItemDto> GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType)
{
ArgumentNullException.ThrowIfNull(filter);
PrepareFilterQuery(filter);
// Early exit if collection type is not supported
if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music)
{
return [];
}
var limit = filter.Limit;
using var context = _dbProvider.CreateDbContext();
var baseQuery = PrepareItemQuery(context, filter);
baseQuery = TranslateQuery(baseQuery, context, filter);
if (collectionType == CollectionType.tvshows)
{
return GetLatestTvShowItems(context, baseQuery, filter, limit);
}
// Find the top N group keys ordered by most recent DateCreated.
// Movies group by PresentationUniqueKey (alternate versions like 4K/1080p share a key).
// Music groups by Album.
Expression<Func<BaseItemEntity, bool>> groupKeyFilter;
Expression<Func<BaseItemEntity, string?>> groupKeySelector;
if (collectionType is CollectionType.movies)
{
groupKeyFilter = e => e.PresentationUniqueKey != null;
groupKeySelector = e => e.PresentationUniqueKey;
}
else
{
groupKeyFilter = e => e.Album != null;
groupKeySelector = e => e.Album;
}
// Group by GroupKey, pick the latest item per group (correlated subquery: ORDER BY DateCreated DESC, Id DESC LIMIT 1),
// order groups by group max date, take the top N — all in a single SQL statement.
// ThenByDescending(Id) is the tiebreaker for deterministic ordering when items share a DateCreated.
var topGroupItems = baseQuery
.Where(groupKeyFilter)
.GroupBy(groupKeySelector)
.Select(g => new
{
MaxDate = g.Max(e => e.DateCreated),
FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First()
})
.OrderByDescending(g => g.MaxDate);
var firstIdsQuery = filter.Limit.HasValue
? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId)
: topGroupItems.Select(g => g.FirstId);
var firstIds = firstIdsQuery.ToList();
// Single bound JSON / array parameter via WhereOneOrMany — keeps SQL small regardless of N.
var itemsQuery = context.BaseItems.AsNoTracking().WhereOneOrMany(firstIds, e => e.Id);
itemsQuery = ApplyNavigations(itemsQuery, filter);
return itemsQuery
.OrderByDescending(e => e.DateCreated)
.ThenByDescending(e => e.Id)
.AsEnumerable()
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
.Where(dto => dto != null)
.ToArray()!;
}
/// <summary>
/// Gets the latest TV show items with smart Season/Series container selection.
/// </summary>
/// <remarks>
/// <para>
/// This method implements intelligent container selection for TV shows in the "Latest" section.
/// Instead of always showing individual episodes, it analyzes recent additions and may return
/// a Season or Series container when multiple related episodes were recently added.
/// </para>
/// <para>
/// The selection logic is:
/// <list type="bullet">
/// <item>If recent episodes span multiple seasons → return the Series</item>
/// <item>If multiple recent episodes are from one season AND the series has multiple seasons → return the Season</item>
/// <item>If multiple recent episodes are from one season AND the series has only one season → return the Series</item>
/// <item>Otherwise → return the most recent Episode</item>
/// </list>
/// </para>
/// </remarks>
/// <param name="context">The database context.</param>
/// <param name="baseQuery">The base query with filters already applied.</param>
/// <param name="filter">The query filter options.</param>
/// <param name="limit">Maximum number of items to return.</param>
/// <returns>A list of BaseItemDto representing the latest TV content.</returns>
private IReadOnlyList<BaseItemDto> GetLatestTvShowItems(JellyfinDbContext context, IQueryable<BaseItemEntity> baseQuery, InternalItemsQuery filter, int? limit)
{
// Episodes added within this window are considered "recently added together"
const double RecentAdditionWindowHours = 24.0;
// Step 1: Find the top N series with recently added content, ordered by most recent addition
var topSeriesWithDates = baseQuery
.Where(e => e.SeriesName != null)
.GroupBy(e => e.SeriesName)
.Select(g => new { SeriesName = g.Key!, MaxDate = g.Max(e => e.DateCreated) })
.OrderByDescending(g => g.MaxDate);
if (limit.HasValue)
{
topSeriesWithDates = topSeriesWithDates.Take(limit.Value).OrderByDescending(g => g.MaxDate);
}
// Materialize series names and cutoff to avoid embedding the GroupBy+OrderBy
// expression tree as a subquery inside the episode query.
var topSeriesData = topSeriesWithDates
.Select(g => new { g.SeriesName, g.MaxDate })
.ToList();
var topSeriesNames = topSeriesData.Select(g => g.SeriesName).ToList();
// Compute a global date cutoff: the oldest series' max date minus the window.
// Episodes before this cutoff cannot be in any series' "recent additions" window,
// so we can safely exclude them to avoid loading ancient episodes.
var globalCutoff = topSeriesData.Count > 0
? topSeriesData.Min(g => g.MaxDate)?.AddHours(-RecentAdditionWindowHours)
: null;
// Restrict to episodes of the top series, optionally bounded by the global cutoff.
var episodeQuery = baseQuery.Where(e => e.SeriesName != null && topSeriesNames.Contains(e.SeriesName));
if (globalCutoff is not null)
{
episodeQuery = episodeQuery.Where(e => e.DateCreated >= globalCutoff);
}
// Lightweight projection: only the columns needed for the in-memory analysis below.
var allEpisodes = episodeQuery
.OrderByDescending(e => e.DateCreated)
.ThenByDescending(e => e.Id)
.Select(e => new { e.Id, e.SeriesName, e.DateCreated, e.SeasonId, e.SeriesId })
.AsEnumerable();
// Collect all season/series IDs we'll need to look up for count information
var allSeasonIds = new HashSet<Guid>();
var allSeriesIds = new HashSet<Guid>();
// Analysis data for each series: recent episode count, season IDs, and the most recent episode ID
var analysisData = new List<(
int RecentEpisodeCount,
List<Guid> SeasonIds,
Guid? FirstRecentSeriesId,
DateTime MaxDate,
Guid MostRecentEpisodeId)>();
// Step 3: Analyze each series to identify recent additions within the time window
foreach (var group in allEpisodes.GroupBy(e => e.SeriesName))
{
var episodes = group.ToList();
var mostRecentDate = episodes[0].DateCreated ?? DateTime.MinValue;
var recentCutoff = mostRecentDate.AddHours(-RecentAdditionWindowHours);
// Find episodes added within the recent window
var recentEpisodeCount = 0;
var seasonIdSet = new HashSet<Guid>();
Guid? firstRecentSeriesId = null;
foreach (var ep in episodes)
{
if (ep.DateCreated >= recentCutoff)
{
recentEpisodeCount++;
if (ep.SeasonId.HasValue)
{
seasonIdSet.Add(ep.SeasonId.Value);
}
firstRecentSeriesId ??= ep.SeriesId;
}
}
var seasonIds = seasonIdSet.ToList();
analysisData.Add((recentEpisodeCount, seasonIds, firstRecentSeriesId, mostRecentDate, episodes[0].Id));
// Track all unique season/series IDs for batch lookups
foreach (var sid in seasonIds)
{
allSeasonIds.Add(sid);
}
if (firstRecentSeriesId.HasValue)
{
allSeriesIds.Add(firstRecentSeriesId.Value);
}
}
// Step 4: Batch fetch counts - episodes per season and seasons per series
// These counts help determine whether to show Season or Series as the container
var episodeType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
var seasonType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Season];
var seasonEpisodeCounts = allSeasonIds.Count > 0
? context.BaseItems
.AsNoTracking()
.Where(e => e.SeasonId.HasValue && allSeasonIds.Contains(e.SeasonId.Value) && e.Type == episodeType)
.GroupBy(e => e.SeasonId!.Value)
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
.ToDictionary(x => x.SeasonId, x => x.Count)
: [];
var seriesSeasonCounts = allSeriesIds.Count > 0
? context.BaseItems
.AsNoTracking()
.Where(e => e.SeriesId.HasValue && allSeriesIds.Contains(e.SeriesId.Value) && e.Type == seasonType)
.GroupBy(e => e.SeriesId!.Value)
.Select(g => new { SeriesId = g.Key, Count = g.Count() })
.ToDictionary(x => x.SeriesId, x => x.Count)
: [];
// Step 5: Apply the container selection logic for each series.
// For each series, decide which entity best represents the recent additions:
// - 1 episode added → show the Episode itself
// - Multiple episodes in 1 season (multi-season series) → show the Season
// - Multiple episodes in 1 season (single-season series) → show the Series
// - Episodes across multiple seasons → show the Series
var entitiesToFetch = new HashSet<Guid>();
var seriesResults = new List<(Guid? SeasonId, Guid? SeriesId, DateTime MaxDate, Guid MostRecentEpisodeId)>(analysisData.Count);
foreach (var (recentEpisodeCount, seasonIds, firstRecentSeriesId, maxDate, mostRecentEpisodeId) in analysisData)
{
Guid? seasonId = null;
Guid? seriesId = null;
if (seasonIds.Count == 1)
{
// All recent episodes are from a single season
var sid = seasonIds[0];
var totalEpisodes = seasonEpisodeCounts.GetValueOrDefault(sid, 0);
var totalSeasonsInSeries = firstRecentSeriesId.HasValue
? seriesSeasonCounts.GetValueOrDefault(firstRecentSeriesId.Value, 1)
: 1;
// Check if multiple episodes were added, or if all episodes in the season were added
var hasMultipleOrAllEpisodes = recentEpisodeCount > 1 || recentEpisodeCount == totalEpisodes;
if (totalSeasonsInSeries > 1 && hasMultipleOrAllEpisodes)
{
// Multi-season series with bulk additions: show the Season
seasonId = sid;
entitiesToFetch.Add(sid);
}
else if (hasMultipleOrAllEpisodes && firstRecentSeriesId.HasValue)
{
// Single-season series with bulk additions: show the Series
seriesId = firstRecentSeriesId;
entitiesToFetch.Add(firstRecentSeriesId.Value);
}
// Otherwise: single episode, will fall through to show the Episode
}
else if (seasonIds.Count > 1 && firstRecentSeriesId.HasValue)
{
// Recent episodes span multiple seasons: show the Series
seriesId = firstRecentSeriesId;
entitiesToFetch.Add(seriesId!.Value);
}
if (seasonId is null && seriesId is null)
{
entitiesToFetch.Add(mostRecentEpisodeId);
}
seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisodeId));
}
// Step 6: Fetch the Season/Series entities we decided to return
var entities = entitiesToFetch.Count > 0
? ApplyNavigations(
context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)),
filter)
.AsSingleQuery()
.ToDictionary(e => e.Id)
: [];
// Step 7: Build final results, preferring Season > Series > Episode.
// All needed entities are already fetched in step 6 with navigation properties.
var results = new List<(BaseItemEntity Entity, DateTime MaxDate)>(seriesResults.Count);
foreach (var (seasonId, seriesId, maxDate, mostRecentEpisodeId) in seriesResults)
{
if (seasonId.HasValue && entities.TryGetValue(seasonId.Value, out var seasonEntity))
{
results.Add((seasonEntity, maxDate));
continue;
}
if (seriesId.HasValue && entities.TryGetValue(seriesId.Value, out var seriesEntity))
{
results.Add((seriesEntity, maxDate));
continue;
}
if (entities.TryGetValue(mostRecentEpisodeId, out var episodeEntity))
{
results.Add((episodeEntity, maxDate));
}
}
var finalResults = results
.OrderByDescending(r => r.MaxDate)
.ThenByDescending(r => r.Entity.Id);
if (limit.HasValue)
{
finalResults = finalResults
.Take(limit.Value)
.OrderByDescending(r => r.MaxDate)
.ThenByDescending(r => r.Entity.Id);
}
return finalResults
.Select(r => DeserializeBaseItem(r.Entity, filter.SkipDeserialization))
.Where(dto => dto is not null)
.ToArray()!;
}
/// <inheritdoc/>
public async Task<bool> ItemExistsAsync(Guid id)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
return await dbContext.BaseItems.AnyAsync(f => f.Id == id).ConfigureAwait(false);
}
}
/// <inheritdoc />
public BaseItemDto? RetrieveItem(Guid id)
{
if (id.IsEmpty())
{
throw new ArgumentException("Guid can't be empty", nameof(id));
}
using var context = _dbProvider.CreateDbContext();
var dbQuery = PrepareItemQuery(context, new()
{
DtoOptions = new()
{
EnableImages = true
}
});
dbQuery = dbQuery.Include(e => e.TrailerTypes)
.Include(e => e.Provider)
.Include(e => e.LockedFields)
.Include(e => e.UserData)
.Include(e => e.Images)
.Include(e => e.LinkedChildEntities)
.AsSingleQuery();
var item = dbQuery.FirstOrDefault(e => e.Id == id);
if (item is null)
{
return null;
}
return DeserializeBaseItem(item);
}
/// <inheritdoc />
public bool GetIsPlayed(User user, Guid id, bool recursive)
{
using var dbContext = _dbProvider.CreateDbContext();
if (recursive)
{
var descendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, id);
return dbContext.BaseItems
.Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem)
.All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
}
return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
}
/// <inheritdoc />
public QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery filter)
{
ArgumentNullException.ThrowIfNull(filter);
PrepareFilterQuery(filter);
using var context = _dbProvider.CreateDbContext();
var baseQuery = PrepareItemQuery(context, filter);
baseQuery = TranslateQuery(baseQuery, context, filter);
var matchingItemIds = baseQuery.Select(e => e.Id);
var years = baseQuery
.Where(e => e.ProductionYear != null && e.ProductionYear > 0)
.Select(e => e.ProductionYear!.Value)
.Distinct()
.OrderBy(y => y)
.ToArray();
var officialRatings = baseQuery
.Where(e => e.OfficialRating != null && e.OfficialRating != string.Empty)
.Select(e => e.OfficialRating!)
.Distinct()
.OrderBy(r => r)
.ToArray();
var tags = context.ItemValuesMap
.Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags)
.Where(ivm => matchingItemIds.Contains(ivm.ItemId))
.Select(ivm => ivm.ItemValue)
.GroupBy(iv => iv.CleanValue)
.Select(g => g.Min(iv => iv.Value))
.OrderBy(t => t)
.ToArray();
var genres = context.ItemValuesMap
.Where(ivm => ivm.ItemValue.Type == ItemValueType.Genre)
.Where(ivm => matchingItemIds.Contains(ivm.ItemId))
.Select(ivm => ivm.ItemValue)
.GroupBy(iv => iv.CleanValue)
.Select(g => g.Min(iv => iv.Value))
.OrderBy(g => g)
.ToArray();
return new QueryFiltersLegacy
{
Years = years,
OfficialRatings = officialRatings,
Tags = tags,
Genres = genres
};
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Linq;
using System.Linq.Expressions;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// Extension methods for applying folder-aware filters that check items and their descendants.
/// </summary>
internal static class FolderAwareFilterExtensions
{
/// <summary>
/// Filters items where either the item matches the condition (for non-folders)
/// or any descendant matches (for folders). Uses reverse traversal through AncestorIds.
/// </summary>
/// <param name="query">The query to filter.</param>
/// <param name="context">The database context.</param>
/// <param name="condition">The condition to check on BaseItemEntity.</param>
/// <returns>Filtered query.</returns>
public static IQueryable<BaseItemEntity> WhereItemOrDescendantMatches(
this IQueryable<BaseItemEntity> query,
JellyfinDbContext context,
Expression<Func<BaseItemEntity, bool>> condition)
{
// Get IDs of items that directly match the condition
var directMatchIds = context.BaseItems.Where(condition).Select(b => b.Id);
// Get parent IDs where a descendant (via AncestorIds) matches
var ancestorMatchIds = context.AncestorIds
.Where(a => directMatchIds.Contains(a.ItemId))
.Select(a => a.ParentItemId);
// Get parent IDs where a linked child matches
var linkedMatchIds = context.LinkedChildren
.Where(lc => directMatchIds.Contains(lc.ChildId))
.Select(lc => lc.ParentId);
var allMatchingIds = directMatchIds
.Concat(ancestorMatchIds)
.Concat(linkedMatchIds)
.Distinct();
return query.Where(e => allMatchingIds.Contains(e.Id));
}
/// <summary>
/// Filters items where neither the item matches the condition (for non-folders)
/// nor any descendant matches (for folders). Uses reverse traversal for infinite depth.
/// </summary>
/// <param name="query">The query to filter.</param>
/// <param name="context">The database context.</param>
/// <param name="condition">The condition that should NOT match.</param>
/// <returns>Filtered query.</returns>
public static IQueryable<BaseItemEntity> WhereNeitherItemNorDescendantMatches(
this IQueryable<BaseItemEntity> query,
JellyfinDbContext context,
Expression<Func<BaseItemEntity, bool>> condition)
{
// Get IDs of items that directly match the condition
var directMatchIds = context.BaseItems.Where(condition).Select(b => b.Id);
// Get parent IDs where a descendant (via AncestorIds) matches
var ancestorMatchIds = context.AncestorIds
.Where(a => directMatchIds.Contains(a.ItemId))
.Select(a => a.ParentItemId);
// Get parent IDs where a linked child matches
var linkedMatchIds = context.LinkedChildren
.Where(lc => directMatchIds.Contains(lc.ChildId))
.Select(lc => lc.ParentId);
var allMatchingIds = directMatchIds
.Concat(ancestorMatchIds)
.Concat(linkedMatchIds)
.Distinct();
return query.Where(e => !allMatchingIds.Contains(e.Id));
}
}

View File

@@ -0,0 +1,427 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Dto;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// Provides item counting and played-status query operations.
/// </summary>
public class ItemCountService : IItemCountService
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IItemTypeLookup _itemTypeLookup;
private readonly IItemQueryHelpers _queryHelpers;
/// <summary>
/// Initializes a new instance of the <see cref="ItemCountService"/> class.
/// </summary>
/// <param name="dbProvider">The database context factory.</param>
/// <param name="itemTypeLookup">The item type lookup.</param>
/// <param name="queryHelpers">The shared query helpers.</param>
public ItemCountService(
IDbContextFactory<JellyfinDbContext> dbProvider,
IItemTypeLookup itemTypeLookup,
IItemQueryHelpers queryHelpers)
{
_dbProvider = dbProvider;
_itemTypeLookup = itemTypeLookup;
_queryHelpers = queryHelpers;
}
/// <inheritdoc/>
public int GetCount(InternalItemsQuery filter)
{
ArgumentNullException.ThrowIfNull(filter);
_queryHelpers.PrepareFilterQuery(filter);
using var context = _dbProvider.CreateDbContext();
var dbQuery = _queryHelpers.TranslateQuery(context.BaseItems.AsNoTracking(), context, filter);
return dbQuery.Count();
}
/// <inheritdoc />
public ItemCounts GetItemCounts(InternalItemsQuery filter)
{
ArgumentNullException.ThrowIfNull(filter);
_queryHelpers.PrepareFilterQuery(filter);
using var context = _dbProvider.CreateDbContext();
var dbQuery = _queryHelpers.TranslateQuery(context.BaseItems.AsNoTracking(), context, filter);
var counts = dbQuery
.GroupBy(x => x.Type)
.Select(x => new { x.Key, Count = x.Count() })
.ToArray();
var lookup = _itemTypeLookup.BaseItemKindNames;
var result = new ItemCounts
{
ItemCount = counts.Sum(c => c.Count)
};
foreach (var count in counts)
{
if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal))
{
result.AlbumCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal))
{
result.ArtistCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal))
{
result.EpisodeCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal))
{
result.MovieCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal))
{
result.MusicVideoCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal))
{
result.ProgramCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal))
{
result.SeriesCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal))
{
result.SongCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal))
{
result.TrailerCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal))
{
result.BoxSetCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal))
{
result.BookCount = count.Count;
}
}
return result;
}
/// <inheritdoc />
public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter)
{
using var context = _dbProvider.CreateDbContext();
var item = context.BaseItems.AsNoTracking()
.Where(e => e.Id == id)
.Select(e => new { e.Name, e.CleanName })
.FirstOrDefault();
if (item is null)
{
return new ItemCounts();
}
IQueryable<BaseItemEntity> baseQuery;
switch (kind)
{
case BaseItemKind.Person:
baseQuery = context.PeopleBaseItemMap
.AsNoTracking()
.Where(m => m.People.Name == item.Name)
.Select(m => m.Item);
break;
case BaseItemKind.MusicArtist:
baseQuery = context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist))
.Select(ivm => ivm.Item);
break;
case BaseItemKind.Genre:
case BaseItemKind.MusicGenre:
baseQuery = context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& ivm.ItemValue.Type == ItemValueType.Genre)
.Select(ivm => ivm.Item);
break;
case BaseItemKind.Studio:
baseQuery = context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& ivm.ItemValue.Type == ItemValueType.Studios)
.Select(ivm => ivm.Item);
break;
case BaseItemKind.Year:
if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
{
baseQuery = context.BaseItems
.AsNoTracking()
.Where(e => e.ProductionYear == year);
}
else
{
return new ItemCounts();
}
break;
default:
return new ItemCounts();
}
var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray();
baseQuery = baseQuery.Where(e => typeNames.Contains(e.Type));
baseQuery = _queryHelpers.ApplyAccessFiltering(context, baseQuery, accessFilter);
var counts = baseQuery
.GroupBy(x => x.Type)
.Select(x => new { x.Key, Count = x.Count() })
.ToArray();
var lookup = _itemTypeLookup.BaseItemKindNames;
var result = new ItemCounts();
var totalCount = 0;
foreach (var count in counts)
{
totalCount += count.Count;
if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal))
{
result.AlbumCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal))
{
result.ArtistCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal))
{
result.EpisodeCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal))
{
result.MovieCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal))
{
result.MusicVideoCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal))
{
result.ProgramCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal))
{
result.SeriesCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal))
{
result.SongCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal))
{
result.TrailerCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal))
{
result.BoxSetCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal))
{
result.BookCount = count.Count;
}
}
result.ItemCount = totalCount;
return result;
}
/// <inheritdoc/>
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
}
/// <inheritdoc/>
public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId)
{
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count();
}
/// <inheritdoc/>
public (int Played, int Total) GetPlayedAndTotalCount(InternalItemsQuery filter, Guid ancestorId)
{
ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
}
/// <inheritdoc/>
public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId)
{
ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId);
var baseQuery = dbContext.BaseItems
.Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem);
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
}
/// <inheritdoc/>
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
{
ArgumentNullException.ThrowIfNull(parentIds);
if (parentIds.Count == 0)
{
return new Dictionary<Guid, int>();
}
using var dbContext = _dbProvider.CreateDbContext();
var parentIdsArray = parentIds.ToArray();
var hierarchicalCounts = dbContext.BaseItems
.Where(b => b.ParentId.HasValue && parentIdsArray.Contains(b.ParentId.Value))
.GroupBy(b => b.ParentId!.Value)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
var linkedCounts = dbContext.LinkedChildren
.Where(lc => parentIdsArray.Contains(lc.ParentId))
.GroupBy(lc => lc.ParentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
var result = new Dictionary<Guid, int>();
foreach (var parentId in parentIds)
{
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount;
}
return result;
}
/// <inheritdoc/>
public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user)
{
ArgumentNullException.ThrowIfNull(folderIds);
ArgumentNullException.ThrowIfNull(user);
if (folderIds.Count == 0)
{
return new Dictionary<Guid, (int Played, int Total)>();
}
using var dbContext = _dbProvider.CreateDbContext();
var folderIdsArray = folderIds.ToArray();
var filter = new InternalItemsQuery(user);
var userId = user.Id;
var leafItems = dbContext.BaseItems
.Where(b => !b.IsFolder && !b.IsVirtualItem);
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
var playedLeafItems = leafItems
.Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
var ancestorLeaves = dbContext.AncestorIds
.WhereOneOrMany(folderIdsArray, a => a.ParentItemId)
.Join(
playedLeafItems,
a => a.ItemId,
b => b.Id,
(a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played });
var linkedLeaves = dbContext.LinkedChildren
.WhereOneOrMany(folderIdsArray, lc => lc.ParentId)
.Join(
playedLeafItems,
lc => lc.ChildId,
b => b.Id,
(lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played });
var linkedFolderLeaves = dbContext.LinkedChildren
.WhereOneOrMany(folderIdsArray, lc => lc.ParentId)
.Join(
dbContext.BaseItems.Where(b => b.IsFolder),
lc => lc.ChildId,
b => b.Id,
(lc, b) => new { lc.ParentId, FolderChildId = b.Id })
.Join(
dbContext.AncestorIds,
x => x.FolderChildId,
a => a.ParentItemId,
(x, a) => new { x.ParentId, DescendantId = a.ItemId })
.Join(
playedLeafItems,
x => x.DescendantId,
b => b.Id,
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
var results = ancestorLeaves
.Union(linkedLeaves)
.Union(linkedFolderLeaves)
.GroupBy(x => x.FolderId)
.Select(g => new
{
FolderId = g.Key,
Total = g.Select(x => x.Id).Distinct().Count(),
Played = g.Where(x => x.Played).Select(x => x.Id).Distinct().Count()
})
.ToDictionary(x => x.FolderId, x => (x.Played, x.Total));
return results;
}
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
{
var result = query
.Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played))
.GroupBy(_ => 1)
.OrderBy(g => g.Key)
.Select(g => new
{
Total = g.Count(),
Played = g.Count(isPlayed => isPlayed)
})
.FirstOrDefault();
return result is null ? (0, 0) : (result.Played, result.Total);
}
}

View File

@@ -0,0 +1,664 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Playlists;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// Handles item persistence operations (save, delete, update).
/// </summary>
public class ItemPersistenceService : IItemPersistenceService
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IServerApplicationHost _appHost;
private readonly ILogger<ItemPersistenceService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ItemPersistenceService"/> class.
/// </summary>
/// <param name="dbProvider">The database context factory.</param>
/// <param name="appHost">The application host.</param>
/// <param name="logger">The logger.</param>
public ItemPersistenceService(
IDbContextFactory<JellyfinDbContext> dbProvider,
IServerApplicationHost appHost,
ILogger<ItemPersistenceService> logger)
{
_dbProvider = dbProvider;
_appHost = appHost;
_logger = logger;
}
/// <inheritdoc />
public void DeleteItem(params IReadOnlyList<Guid> ids)
{
if (ids is null || ids.Count == 0 || ids.Any(f => f.Equals(BaseItemRepository.PlaceholderId)))
{
throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(ids));
}
using var context = _dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
var date = (DateTime?)DateTime.UtcNow;
var descendantIds = DescendantQueryHelper.GetOwnedDescendantIdsBatch(context, ids);
foreach (var id in ids)
{
descendantIds.Add(id);
}
var extraIds = context.BaseItems
.Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value))
.Select(e => e.Id)
.ToArray();
foreach (var extraId in extraIds)
{
descendantIds.Add(extraId);
}
var relatedItems = descendantIds.ToArray();
// When batch-deleting, multiple items may have UserData for the same (UserId, CustomDataKey).
// Moving all of them to PlaceholderId would violate the UNIQUE constraint.
// Deduplicate by loading keys client-side, keeping the best row per group.
var batchUserData = context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId);
var allRows = batchUserData
.Select(ud => new { ud.ItemId, ud.UserId, ud.CustomDataKey, ud.LastPlayedDate, ud.PlayCount })
.ToList();
var duplicateRows = allRows
.GroupBy(ud => new { ud.UserId, ud.CustomDataKey })
.Where(g => g.Count() > 1)
.SelectMany(g => g
.OrderByDescending(ud => ud.LastPlayedDate)
.ThenByDescending(ud => ud.PlayCount)
.Skip(1))
.ToList();
foreach (var dup in duplicateRows)
{
context.UserData
.Where(ud => ud.ItemId == dup.ItemId && ud.UserId == dup.UserId && ud.CustomDataKey == dup.CustomDataKey)
.ExecuteDelete();
}
// Delete existing placeholder rows that would conflict with the incoming ones
context.UserData
.Join(
batchUserData,
placeholder => new { placeholder.UserId, placeholder.CustomDataKey },
userData => new { userData.UserId, userData.CustomDataKey },
(placeholder, userData) => placeholder)
.Where(e => e.ItemId == BaseItemRepository.PlaceholderId)
.ExecuteDelete();
batchUserData
.ExecuteUpdate(e => e
.SetProperty(f => f.RetentionDate, date)
.SetProperty(f => f.ItemId, BaseItemRepository.PlaceholderId));
context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ParentItemId).ExecuteDelete();
context.AttachmentStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.BaseItemImageInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete();
context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete();
context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete();
context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete();
context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
var query = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray();
context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDelete();
context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.SaveChanges();
transaction.Commit();
}
/// <inheritdoc />
public void UpdateInheritedValues()
{
using var context = _dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
context.ItemValuesMap.Where(e => e.ItemValue.Type == ItemValueType.InheritedTags).ExecuteDelete();
context.SaveChanges();
transaction.Commit();
}
/// <inheritdoc />
public void SaveItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken)
{
UpdateOrInsertItems(items, cancellationToken);
}
/// <inheritdoc />
public async Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(item);
var images = item.ImageInfos.Select(e => BaseItemMapper.MapImageToEntity(item.Id, e)).ToArray();
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
if (!await context.BaseItems
.AnyAsync(bi => bi.Id == item.Id, cancellationToken)
.ConfigureAwait(false))
{
_logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
return;
}
await context.BaseItemImageInfos
.Where(e => e.ItemId == item.Id)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.BaseItemImageInfos
.AddRangeAsync(images, cancellationToken)
.ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(item);
cancellationToken.ThrowIfCancellationRequested();
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
var userKeys = item.GetUserDataKeys().ToArray();
var retentionDate = (DateTime?)null;
await dbContext.UserData
.Where(e => e.ItemId == BaseItemRepository.PlaceholderId)
.Where(e => userKeys.Contains(e.CustomDataKey))
.ExecuteUpdateAsync(
e => e
.SetProperty(f => f.ItemId, item.Id)
.SetProperty(f => f.RetentionDate, retentionDate),
cancellationToken).ConfigureAwait(false);
item.UserData = await dbContext.UserData
.AsNoTracking()
.Where(e => e.ItemId == item.Id)
.ToArrayAsync(cancellationToken)
.ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
}
}
private void UpdateOrInsertItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(items);
cancellationToken.ThrowIfCancellationRequested();
var tuples = new List<(BaseItemDto Item, List<Guid>? AncestorIds, BaseItemDto TopParent, IEnumerable<string> UserDataKey, List<string> InheritedTags)>();
foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()).Where(e => e.Id != BaseItemRepository.PlaceholderId))
{
var ancestorIds = item.SupportsAncestors ?
item.GetAncestorIds().Distinct().ToList() :
null;
var topParent = item.GetTopParent();
var userdataKey = item.GetUserDataKeys();
var inheritedTags = item.GetInheritedTags();
tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags));
}
using var context = _dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
var ids = tuples.Select(f => f.Item.Id).ToArray();
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
foreach (var item in tuples)
{
var entity = BaseItemMapper.Map(item.Item, _appHost);
entity.TopParentId = item.TopParent?.Id;
if (!existingItems.Any(e => e == entity.Id))
{
context.BaseItems.Add(entity);
}
else
{
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
if (entity.Images is { Count: > 0 })
{
context.BaseItemImageInfos.AddRange(entity.Images);
}
if (entity.LockedFields is { Count: > 0 })
{
context.BaseItemMetadataFields.AddRange(entity.LockedFields);
}
context.BaseItems.Attach(entity).State = EntityState.Modified;
}
}
var itemValueMaps = tuples
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
.ToArray();
var allListedItemValues = itemValueMaps
.SelectMany(f => f.Values)
.Distinct()
.ToArray();
var types = allListedItemValues.Select(e => e.MagicNumber).Distinct().ToArray();
var values = allListedItemValues.Select(e => e.Value).Distinct().ToArray();
var allListedItemValuesSet = allListedItemValues.ToHashSet();
var existingValues = context.ItemValues
.Where(e => types.Contains(e.Type) && values.Contains(e.Value))
.AsEnumerable()
.Where(e => allListedItemValuesSet.Contains((e.Type, e.Value)))
.ToArray();
var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).Select(f => new ItemValue()
{
CleanValue = f.Value.GetCleanValue(),
ItemValueId = Guid.NewGuid(),
Type = f.MagicNumber,
Value = f.Value
}).ToArray();
context.ItemValues.AddRange(missingItemValues);
var itemValuesStore = existingValues.Concat(missingItemValues).ToArray();
var valueMap = itemValueMaps
.Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray()))
.ToArray();
var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList();
foreach (var item in valueMap)
{
var itemMappedValues = mappedValues.Where(e => e.ItemId == item.Item.Id).ToList();
foreach (var itemValue in item.Values)
{
var existingItem = itemMappedValues.FirstOrDefault(f => f.ItemValueId == itemValue.ItemValueId);
if (existingItem is null)
{
context.ItemValuesMap.Add(new ItemValueMap()
{
Item = null!,
ItemId = item.Item.Id,
ItemValue = null!,
ItemValueId = itemValue.ItemValueId
});
}
else
{
itemMappedValues.Remove(existingItem);
}
}
context.ItemValuesMap.RemoveRange(itemMappedValues);
}
var itemsWithAncestors = tuples
.Where(t => t.Item.SupportsAncestors && t.AncestorIds != null)
.Select(t => t.Item.Id)
.ToList();
var allExistingAncestorIds = itemsWithAncestors.Count > 0
? context.AncestorIds
.Where(e => itemsWithAncestors.Contains(e.ItemId))
.ToList()
.GroupBy(e => e.ItemId)
.ToDictionary(g => g.Key, g => g.ToList())
: new Dictionary<Guid, List<AncestorId>>();
var allRequestedAncestorIds = tuples
.Where(t => t.Item.SupportsAncestors && t.AncestorIds != null)
.SelectMany(t => t.AncestorIds!)
.Distinct()
.ToList();
var validAncestorIdsSet = allRequestedAncestorIds.Count > 0
? context.BaseItems
.Where(e => allRequestedAncestorIds.Contains(e.Id))
.Select(f => f.Id)
.ToHashSet()
: new HashSet<Guid>();
foreach (var item in tuples)
{
if (item.Item.SupportsAncestors && item.AncestorIds != null)
{
var existingAncestorIds = allExistingAncestorIds.GetValueOrDefault(item.Item.Id) ?? new List<AncestorId>();
var validAncestorIds = item.AncestorIds.Where(id => validAncestorIdsSet.Contains(id)).ToArray();
foreach (var ancestorId in validAncestorIds)
{
var existingAncestorId = existingAncestorIds.FirstOrDefault(e => e.ParentItemId == ancestorId);
if (existingAncestorId is null)
{
context.AncestorIds.Add(new AncestorId()
{
ParentItemId = ancestorId,
ItemId = item.Item.Id,
Item = null!,
ParentItem = null!
});
}
else
{
existingAncestorIds.Remove(existingAncestorId);
}
}
context.AncestorIds.RemoveRange(existingAncestorIds);
}
}
context.SaveChanges();
var folderIds = tuples
.Where(t => t.Item is Folder)
.Select(t => t.Item.Id)
.ToList();
var videoIds = tuples
.Where(t => t.Item is Video)
.Select(t => t.Item.Id)
.ToList();
var allLinkedChildrenByParent = new Dictionary<Guid, List<LinkedChildEntity>>();
if (folderIds.Count > 0 || videoIds.Count > 0)
{
var allParentIds = folderIds.Concat(videoIds).Distinct().ToList();
var allLinkedChildren = context.LinkedChildren
.Where(e => allParentIds.Contains(e.ParentId))
.ToList();
allLinkedChildrenByParent = allLinkedChildren
.GroupBy(e => e.ParentId)
.ToDictionary(g => g.Key, g => g.ToList());
}
foreach (var item in tuples)
{
if (item.Item is Folder folder)
{
var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new List<LinkedChildEntity>();
if (folder.LinkedChildren.Length > 0)
{
#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data
var pathsToResolve = folder.LinkedChildren
.Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path))
.Select(lc => lc.Path)
.Distinct()
.ToList();
var pathToIdMap = pathsToResolve.Count > 0
? context.BaseItems
.Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
.Select(e => new { e.Path, e.Id })
.GroupBy(e => e.Path!)
.ToDictionary(g => g.Key, g => g.First().Id)
: [];
var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
foreach (var linkedChild in folder.LinkedChildren)
{
var childItemId = linkedChild.ItemId;
if (!childItemId.HasValue || childItemId.Value.IsEmpty())
{
if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
{
childItemId = resolvedId;
}
}
#pragma warning restore CS0618
if (childItemId.HasValue && !childItemId.Value.IsEmpty())
{
resolvedChildren.Add((linkedChild, childItemId.Value));
}
}
resolvedChildren = resolvedChildren
.GroupBy(c => c.ChildId)
.Select(g => g.Last())
.ToList();
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList();
var existingChildIds = childIdsToCheck.Count > 0
? context.BaseItems
.Where(e => childIdsToCheck.Contains(e.Id))
.Select(e => e.Id)
.ToHashSet()
: [];
var isPlaylist = folder is Playlist;
var sortOrder = 0;
foreach (var (linkedChild, childId) in resolvedChildren)
{
if (!existingChildIds.Contains(childId))
{
_logger.LogWarning(
"Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database",
item.Item.Name,
item.Item.Id,
childId);
continue;
}
var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
if (existingLink is null)
{
context.LinkedChildren.Add(new LinkedChildEntity()
{
ParentId = item.Item.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)linkedChild.Type,
SortOrder = isPlaylist ? sortOrder : null
});
}
else
{
existingLink.SortOrder = isPlaylist ? sortOrder : null;
existingLink.ChildType = (DbLinkedChildType)linkedChild.Type;
existingLinkedChildren.Remove(existingLink);
}
sortOrder++;
}
}
if (existingLinkedChildren.Count > 0)
{
context.LinkedChildren.RemoveRange(existingLinkedChildren);
}
}
if (item.Item is Video video)
{
var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedChildEntity>())
.Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3)
.ToList();
var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>();
if (video.LocalAlternateVersions.Length > 0)
{
var pathsToResolve = video.LocalAlternateVersions.Where(p => !string.IsNullOrEmpty(p)).ToList();
if (pathsToResolve.Count > 0)
{
var pathToIdMap = context.BaseItems
.Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
.Select(e => new { e.Path, e.Id })
.GroupBy(e => e.Path!)
.ToDictionary(g => g.Key, g => g.First().Id);
foreach (var path in pathsToResolve)
{
if (pathToIdMap.TryGetValue(path, out var childId))
{
newLinkedChildren.Add((childId, LinkedChildType.LocalAlternateVersion));
}
}
}
}
if (video.LinkedAlternateVersions.Length > 0)
{
foreach (var linkedChild in video.LinkedAlternateVersions)
{
if (linkedChild.ItemId.HasValue && !linkedChild.ItemId.Value.IsEmpty())
{
newLinkedChildren.Add((linkedChild.ItemId.Value, LinkedChildType.LinkedAlternateVersion));
}
}
}
newLinkedChildren = newLinkedChildren
.GroupBy(c => c.ChildId)
.Select(g => g.Last())
.ToList();
var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();
var existingChildIds = childIdsToCheck.Count > 0
? context.BaseItems
.Where(e => childIdsToCheck.Contains(e.Id))
.Select(e => e.Id)
.ToHashSet()
: [];
int sortOrder = 0;
foreach (var (childId, childType) in newLinkedChildren)
{
if (!existingChildIds.Contains(childId))
{
_logger.LogWarning(
"Skipping alternate version for video {VideoName} ({VideoId}): child item {ChildId} does not exist in database",
video.Name,
video.Id,
childId);
continue;
}
var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
if (existingLink is null)
{
context.LinkedChildren.Add(new LinkedChildEntity
{
ParentId = video.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)childType,
SortOrder = sortOrder
});
}
else
{
existingLink.ChildType = (DbLinkedChildType)childType;
existingLink.SortOrder = sortOrder;
existingLinkedChildren.Remove(existingLink);
}
sortOrder++;
}
if (existingLinkedChildren.Count > 0)
{
var orphanedLocalVersionIds = existingLinkedChildren
.Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion)
.Select(e => e.ChildId)
.ToList();
context.LinkedChildren.RemoveRange(existingLinkedChildren);
if (orphanedLocalVersionIds.Count > 0)
{
var orphanedItems = context.BaseItems
.Where(e => orphanedLocalVersionIds.Contains(e.Id) && e.OwnerId == video.Id)
.ToList();
if (orphanedItems.Count > 0)
{
_logger.LogInformation(
"Deleting {Count} orphaned LocalAlternateVersion items for video {VideoName} ({VideoId})",
orphanedItems.Count,
video.Name,
video.Id);
context.BaseItems.RemoveRange(orphanedItems);
}
}
}
}
}
context.SaveChanges();
transaction.Commit();
}
private static List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags)
{
var list = new List<(ItemValueType, string)>();
if (item is IHasArtist hasArtist)
{
list.AddRange(hasArtist.Artists.Select(i => ((ItemValueType)0, i)));
}
if (item is IHasAlbumArtist hasAlbumArtist)
{
list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (ItemValueType.AlbumArtist, i)));
}
list.AddRange(item.Genres.Select(i => (ItemValueType.Genre, i)));
list.AddRange(item.Studios.Select(i => (ItemValueType.Studios, i)));
list.AddRange(item.Tags.Select(i => (ItemValueType.Tags, i)));
list.AddRange(inheritedTags.Select(i => (ItemValueType.InheritedTags, i)));
list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2));
return list;
}
}

View File

@@ -0,0 +1,162 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Persistence;
using Microsoft.EntityFrameworkCore;
using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// Provides linked children query and manipulation operations.
/// </summary>
public class LinkedChildrenService : ILinkedChildrenService
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IItemTypeLookup _itemTypeLookup;
private readonly IItemQueryHelpers _queryHelpers;
/// <summary>
/// Initializes a new instance of the <see cref="LinkedChildrenService"/> class.
/// </summary>
/// <param name="dbProvider">The database context factory.</param>
/// <param name="itemTypeLookup">The item type lookup.</param>
/// <param name="queryHelpers">The shared query helpers.</param>
public LinkedChildrenService(
IDbContextFactory<JellyfinDbContext> dbProvider,
IItemTypeLookup itemTypeLookup,
IItemQueryHelpers queryHelpers)
{
_dbProvider = dbProvider;
_itemTypeLookup = itemTypeLookup;
_queryHelpers = queryHelpers;
}
/// <inheritdoc/>
public IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null)
{
using var dbContext = _dbProvider.CreateDbContext();
var query = dbContext.LinkedChildren
.Where(lc => lc.ParentId.Equals(parentId));
if (childType.HasValue)
{
query = query.Where(lc => (int)lc.ChildType == childType.Value);
}
return query
.OrderBy(lc => lc.SortOrder)
.Select(lc => lc.ChildId)
.ToArray();
}
/// <inheritdoc/>
public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames)
{
using var dbContext = _dbProvider.CreateDbContext();
var artists = dbContext.BaseItems
.AsNoTracking()
.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!)
.Where(e => artistNames.Contains(e.Name))
.ToArray();
var lookup = artists
.GroupBy(e => e.Name!)
.ToDictionary(
g => g.Key,
g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
foreach (var name in artistNames)
{
if (lookup.TryGetValue(name, out var artistArray))
{
result[name] = artistArray;
}
}
return result;
}
/// <inheritdoc/>
public IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId)
{
using var context = _dbProvider.CreateDbContext();
return context.LinkedChildren
.Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual)
.Select(lc => lc.ParentId)
.Distinct()
.ToList();
}
/// <inheritdoc/>
public IReadOnlyList<Guid> RerouteLinkedChildren(Guid fromChildId, Guid toChildId)
{
using var context = _dbProvider.CreateDbContext();
var affectedParentIds = context.LinkedChildren
.Where(lc => lc.ChildId == fromChildId && lc.ChildType == DbLinkedChildType.Manual)
.Select(lc => lc.ParentId)
.Distinct()
.ToList();
if (affectedParentIds.Count == 0)
{
return affectedParentIds;
}
var parentsWithTarget = context.LinkedChildren
.Where(lc => lc.ChildId == toChildId && lc.ChildType == DbLinkedChildType.Manual)
.Select(lc => lc.ParentId)
.ToHashSet();
context.LinkedChildren
.Where(lc => lc.ChildId == fromChildId
&& lc.ChildType == DbLinkedChildType.Manual
&& !parentsWithTarget.Contains(lc.ParentId))
.ExecuteUpdate(s => s.SetProperty(e => e.ChildId, toChildId));
context.LinkedChildren
.Where(lc => lc.ChildId == fromChildId
&& lc.ChildType == DbLinkedChildType.Manual
&& parentsWithTarget.Contains(lc.ParentId))
.ExecuteDelete();
return affectedParentIds;
}
/// <inheritdoc/>
public void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType)
{
using var context = _dbProvider.CreateDbContext();
var dbChildType = (DbLinkedChildType)childType;
var existingLink = context.LinkedChildren
.FirstOrDefault(lc => lc.ParentId == parentId && lc.ChildId == childId);
if (existingLink is null)
{
context.LinkedChildren.Add(new Jellyfin.Database.Implementations.Entities.LinkedChildEntity
{
ParentId = parentId,
ChildId = childId,
ChildType = dbChildType,
SortOrder = null
});
}
else
{
existingLink.ChildType = dbChildType;
}
context.SaveChanges();
}
}

View File

@@ -0,0 +1,353 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Persistence;
using Microsoft.EntityFrameworkCore;
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// Provides next-up episode query operations.
/// </summary>
public class NextUpService : INextUpService
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IItemTypeLookup _itemTypeLookup;
private readonly IItemQueryHelpers _queryHelpers;
/// <summary>
/// Initializes a new instance of the <see cref="NextUpService"/> class.
/// </summary>
/// <param name="dbProvider">The database context factory.</param>
/// <param name="itemTypeLookup">The item type lookup.</param>
/// <param name="queryHelpers">The shared query helpers.</param>
public NextUpService(
IDbContextFactory<JellyfinDbContext> dbProvider,
IItemTypeLookup itemTypeLookup,
IItemQueryHelpers queryHelpers)
{
_dbProvider = dbProvider;
_itemTypeLookup = itemTypeLookup;
_queryHelpers = queryHelpers;
}
/// <inheritdoc />
public IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff)
{
ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
using var context = _dbProvider.CreateDbContext();
var query = context.BaseItems
.AsNoTracking()
.Where(i => filter.TopParentIds.Contains(i.TopParentId!.Value))
.Where(i => i.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode])
.Join(
context.UserData.AsNoTracking().Where(e => e.ItemId != EF.Constant(BaseItemRepository.PlaceholderId)),
i => new { UserId = filter.User.Id, ItemId = i.Id },
u => new { u.UserId, u.ItemId },
(entity, data) => new { Item = entity, UserData = data })
.GroupBy(g => g.Item.SeriesPresentationUniqueKey)
.Select(g => new { g.Key, LastPlayedDate = g.Max(u => u.UserData.LastPlayedDate) })
.Where(g => g.Key != null && g.LastPlayedDate != null && g.LastPlayedDate >= dateCutoff)
.OrderByDescending(g => g.LastPlayedDate)
.Select(g => g.Key!);
if (filter.Limit.HasValue)
{
query = query.Take(filter.Limit.Value);
}
return query.ToArray();
}
/// <inheritdoc />
public IReadOnlyDictionary<string, NextUpEpisodeBatchResult> GetNextUpEpisodesBatch(
InternalItemsQuery filter,
IReadOnlyList<string> seriesKeys,
bool includeSpecials,
bool includeWatchedForRewatching)
{
ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
if (seriesKeys.Count == 0)
{
return new Dictionary<string, NextUpEpisodeBatchResult>();
}
_queryHelpers.PrepareFilterQuery(filter);
using var context = _dbProvider.CreateDbContext();
var userId = filter.User.Id;
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
var lastWatchedBase = context.BaseItems
.AsNoTracking()
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter);
// Use lightweight projection + client-side grouping to avoid correlated scalar subquery
// per group that EF generates for GroupBy+OrderByDescending+FirstOrDefault.
var allPlayedLite = lastWatchedBase
.Select(e => new
{
e.Id,
e.SeriesPresentationUniqueKey,
e.ParentIndexNumber,
e.IndexNumber
})
.ToList();
var lastWatchedInfo = new Dictionary<string, Guid>();
foreach (var group in allPlayedLite.GroupBy(e => e.SeriesPresentationUniqueKey))
{
var lastWatched = group
.OrderByDescending(e => e.ParentIndexNumber)
.ThenByDescending(e => e.IndexNumber)
.First();
lastWatchedInfo[group.Key!] = lastWatched.Id;
}
Dictionary<string, Guid> lastWatchedByDateInfo = new();
if (includeWatchedForRewatching)
{
var lastWatchedByDateBase = context.BaseItems
.AsNoTracking()
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
lastWatchedByDateBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedByDateBase, filter);
// Use lightweight projection + client-side grouping instead of
// SelectMany+GroupBy+OrderByDescending+FirstOrDefault (correlated subquery).
var playedWithDates = lastWatchedByDateBase
.SelectMany(e => e.UserData!.Where(ud => ud.UserId == userId && ud.Played)
.Select(ud => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate }))
.ToList();
foreach (var group in playedWithDates.GroupBy(x => x.SeriesPresentationUniqueKey))
{
var mostRecent = group.OrderByDescending(x => x.LastPlayedDate).First();
lastWatchedByDateInfo[group.Key!] = mostRecent.EpisodeId;
}
}
var allLastWatchedIds = lastWatchedInfo.Values
.Concat(lastWatchedByDateInfo.Values)
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
var lwQuery = context.BaseItems.AsNoTracking().Where(e => allLastWatchedIds.Contains(e.Id));
lwQuery = _queryHelpers.ApplyNavigations(lwQuery, filter);
var lastWatchedEpisodes = lwQuery.ToDictionary(e => e.Id);
Dictionary<string, List<BaseItemEntity>> specialsBySeriesKey = new();
if (includeSpecials)
{
var specialsQuery = context.BaseItems
.AsNoTracking()
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber == 0)
.Where(e => !e.IsVirtualItem);
specialsQuery = _queryHelpers.ApplyAccessFiltering(context, specialsQuery, filter);
specialsQuery = _queryHelpers.ApplyNavigations(specialsQuery, filter).AsSingleQuery();
foreach (var special in specialsQuery)
{
var key = special.SeriesPresentationUniqueKey!;
if (!specialsBySeriesKey.TryGetValue(key, out var list))
{
list = new List<BaseItemEntity>();
specialsBySeriesKey[key] = list;
}
list.Add(special);
}
}
var positionLookup = new Dictionary<string, (int Season, int Episode)>();
foreach (var kvp in lastWatchedInfo)
{
if (kvp.Value != Guid.Empty
&& lastWatchedEpisodes.TryGetValue(kvp.Value, out var lw)
&& lw.ParentIndexNumber.HasValue
&& lw.IndexNumber.HasValue)
{
positionLookup[kvp.Key] = (lw.ParentIndexNumber.Value, lw.IndexNumber.Value);
}
}
var allUnplayedBase = context.BaseItems
.AsNoTracking()
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => !e.IsVirtualItem)
.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter);
var allUnplayedCandidates = allUnplayedBase
.Select(e => new
{
e.Id,
e.SeriesPresentationUniqueKey,
e.ParentIndexNumber,
EpisodeNumber = e.IndexNumber
})
.ToList();
var nextEpisodeIds = new HashSet<Guid>();
var seriesNextIdMap = new Dictionary<string, Guid>();
foreach (var seriesKey in seriesKeys)
{
var candidates = allUnplayedCandidates
.Where(c => c.SeriesPresentationUniqueKey == seriesKey);
if (positionLookup.TryGetValue(seriesKey, out var pos))
{
candidates = candidates.Where(c =>
c.ParentIndexNumber > pos.Season
|| (c.ParentIndexNumber == pos.Season && c.EpisodeNumber > pos.Episode));
}
var nextCandidate = candidates
.OrderBy(c => c.ParentIndexNumber)
.ThenBy(c => c.EpisodeNumber)
.FirstOrDefault();
if (nextCandidate is not null && nextCandidate.Id != Guid.Empty)
{
nextEpisodeIds.Add(nextCandidate.Id);
seriesNextIdMap[seriesKey] = nextCandidate.Id;
}
}
var seriesNextPlayedIdMap = new Dictionary<string, Guid>();
if (includeWatchedForRewatching)
{
var allPlayedBase = context.BaseItems
.AsNoTracking()
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => !e.IsVirtualItem)
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter);
var allPlayedCandidates = allPlayedBase
.Select(e => new
{
e.Id,
e.SeriesPresentationUniqueKey,
e.ParentIndexNumber,
EpisodeNumber = e.IndexNumber
})
.ToList();
foreach (var seriesKey in seriesKeys)
{
if (!lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId))
{
continue;
}
var lastByDateEntity = lastWatchedEpisodes.GetValueOrDefault(lastByDateId);
if (lastByDateEntity is null)
{
continue;
}
var playedCandidates = allPlayedCandidates
.Where(c => c.SeriesPresentationUniqueKey == seriesKey);
if (lastByDateEntity.ParentIndexNumber.HasValue && lastByDateEntity.IndexNumber.HasValue)
{
var lastSeason = lastByDateEntity.ParentIndexNumber.Value;
var lastEp = lastByDateEntity.IndexNumber.Value;
playedCandidates = playedCandidates.Where(c =>
c.ParentIndexNumber > lastSeason
|| (c.ParentIndexNumber == lastSeason && c.EpisodeNumber > lastEp));
}
var nextPlayedCandidate = playedCandidates
.OrderBy(c => c.ParentIndexNumber)
.ThenBy(c => c.EpisodeNumber)
.FirstOrDefault();
if (nextPlayedCandidate is not null && nextPlayedCandidate.Id != Guid.Empty)
{
nextEpisodeIds.Add(nextPlayedCandidate.Id);
seriesNextPlayedIdMap[seriesKey] = nextPlayedCandidate.Id;
}
}
}
var nextEpisodes = new Dictionary<Guid, BaseItemEntity>();
if (nextEpisodeIds.Count > 0)
{
var nextQuery = context.BaseItems.AsNoTracking().Where(e => nextEpisodeIds.Contains(e.Id));
nextQuery = _queryHelpers.ApplyNavigations(nextQuery, filter).AsSingleQuery();
nextEpisodes = nextQuery.ToDictionary(e => e.Id);
}
var result = new Dictionary<string, NextUpEpisodeBatchResult>();
foreach (var seriesKey in seriesKeys)
{
var batchResult = new NextUpEpisodeBatchResult();
if (lastWatchedInfo.TryGetValue(seriesKey, out var lwId) && lwId != Guid.Empty)
{
if (lastWatchedEpisodes.TryGetValue(lwId, out var entity))
{
batchResult.LastWatched = _queryHelpers.DeserializeBaseItem(entity, filter.SkipDeserialization);
}
}
if (seriesNextIdMap.TryGetValue(seriesKey, out var nextId) && nextEpisodes.TryGetValue(nextId, out var nextEntity))
{
batchResult.NextUp = _queryHelpers.DeserializeBaseItem(nextEntity, filter.SkipDeserialization);
}
if (includeSpecials && specialsBySeriesKey.TryGetValue(seriesKey, out var specials))
{
batchResult.Specials = specials.Select(s => _queryHelpers.DeserializeBaseItem(s, filter.SkipDeserialization)!).ToList();
}
else
{
batchResult.Specials = Array.Empty<BaseItemDto>();
}
if (includeWatchedForRewatching)
{
if (lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId) &&
lastWatchedEpisodes.TryGetValue(lastByDateId, out var lastByDateEntity))
{
batchResult.LastWatchedForRewatching = _queryHelpers.DeserializeBaseItem(lastByDateEntity, filter.SkipDeserialization);
}
if (seriesNextPlayedIdMap.TryGetValue(seriesKey, out var nextPlayedId) &&
nextEpisodes.TryGetValue(nextPlayedId, out var nextPlayedEntity))
{
batchResult.NextPlayedForRewatching = _queryHelpers.DeserializeBaseItem(nextPlayedEntity, filter.SkipDeserialization);
}
}
result[seriesKey] = batchResult;
}
return result;
}
}

View File

@@ -1,4 +1,7 @@
#pragma warning disable RS0030 // Do not use banned APIs
#pragma warning disable CA1304 // Specify CultureInfo
#pragma warning disable CA1311 // Specify a culture or use an invariant version
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
using System;
using System.Linq;
@@ -28,19 +31,19 @@ public static class OrderMapper
{
return (sortBy, query.User) switch
{
(ItemSortBy.AirTime, _) => e => e.SortName, // TODO
(ItemSortBy.AirTime, _) => e => e.SortName,
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
(ItemSortBy.DatePlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
(ItemSortBy.PlayCount, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
(ItemSortBy.DatePlayed, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.LastPlayedDate,
(ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount,
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false,
(ItemSortBy.IsFolder, _) => e => e.IsFolder,
(ItemSortBy.IsPlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
(ItemSortBy.IsUnplayed, _) => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
(ItemSortBy.IsPlayed, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.Played,
(ItemSortBy.IsUnplayed, _) => e => !e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.Played,
(ItemSortBy.DateLastContentAdded, _) => e => e.DateLastMediaAdded,
(ItemSortBy.Artist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.Artist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).OrderBy(f => f.ItemValue.CleanValue).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).OrderBy(f => f.ItemValue.CleanValue).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).OrderBy(f => f.ItemValue.CleanValue).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue,
(ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
(ItemSortBy.Album, _) => e => e.Album,
@@ -54,18 +57,16 @@ public static class OrderMapper
(ItemSortBy.VideoBitRate, _) => e => e.TotalBitrate,
(ItemSortBy.ParentIndexNumber, _) => e => e.ParentIndexNumber,
(ItemSortBy.IndexNumber, _) => e => e.IndexNumber,
// SeriesDatePlayed is normally handled via pre-aggregated join in ApplySeriesDatePlayedOrder.
// This correlated subquery fallback is only reached when combined with search.
(ItemSortBy.SeriesDatePlayed, not null) => e =>
jellyfinDbContext.BaseItems
.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
.Max(f => f),
(ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
.Max(f => f),
// ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData
// .Where(u => u.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey && u.Played)
// .Max(f => f.LastPlayedDate),
// ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder",
jellyfinDbContext.UserData
.Where(w => w.UserId == query.User.Id && w.Played && w.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Max(f => f.LastPlayedDate),
(ItemSortBy.SeriesDatePlayed, null) => e =>
jellyfinDbContext.UserData
.Where(w => w.Played && w.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Max(f => f.LastPlayedDate),
_ => e => e.SortName
};
}
@@ -73,6 +74,7 @@ public static class OrderMapper
/// <summary>
/// Creates an expression to order search results by match quality.
/// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3).
/// Considers both CleanName and OriginalTitle for matching.
/// </summary>
/// <param name="searchTerm">The search term to match against.</param>
/// <returns>An expression that returns an integer representing match quality (lower is better).</returns>
@@ -80,10 +82,15 @@ public static class OrderMapper
{
var cleanSearchTerm = GetCleanValue(searchTerm);
var searchPrefix = cleanSearchTerm + " ";
var originalSearchLower = searchTerm.ToLowerInvariant();
var originalSearchPrefix = originalSearchLower + " ";
return e =>
e.CleanName == cleanSearchTerm ? 0 :
e.CleanName!.StartsWith(searchPrefix) ? 1 :
e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3;
// Exact match on CleanName or OriginalTitle
(e.CleanName == cleanSearchTerm || (e.OriginalTitle != null && e.OriginalTitle.ToLower() == originalSearchLower)) ? 0 :
// Prefix match with word boundary
(e.CleanName!.StartsWith(searchPrefix) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().StartsWith(originalSearchPrefix))) ? 1 :
// Prefix match
(e.CleanName!.StartsWith(cleanSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().StartsWith(originalSearchLower))) ? 2 : 3;
}
private static string GetCleanValue(string value)

View File

@@ -69,7 +69,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.Limit > 0)
{
dbQuery = dbQuery.Take(filter.Limit);
dbQuery = dbQuery.OrderBy(e => e).Take(filter.Limit);
}
return dbQuery.ToArray();
@@ -226,7 +226,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty())
{
query = query.Where(e => e.BaseItems!.First(w => w.ItemId == filter.ItemId).ListOrder <= filter.MaxListOrder.Value);
query = query.Where(e => e.BaseItems!.Where(w => w.ItemId == filter.ItemId).OrderBy(w => w.ListOrder).First().ListOrder <= filter.MaxListOrder.Value);
}
if (!string.IsNullOrWhiteSpace(filter.NameContains))

View File

@@ -93,7 +93,7 @@ namespace Jellyfin.Server.Implementations.Users
_users = new ConcurrentDictionary<Guid, User>();
using var dbContext = _dbProvider.CreateDbContext();
foreach (var user in dbContext.Users
.AsSplitQuery()
.AsSingleQuery()
.Include(user => user.Permissions)
.Include(user => user.Preferences)
.Include(user => user.AccessSchedules)
@@ -607,6 +607,7 @@ namespace Jellyfin.Server.Implementations.Users
.Include(u => u.Preferences)
.Include(u => u.AccessSchedules)
.Include(u => u.ProfileImage)
.AsSingleQuery()
.FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");
@@ -651,6 +652,7 @@ namespace Jellyfin.Server.Implementations.Users
.Include(u => u.Preferences)
.Include(u => u.AccessSchedules)
.Include(u => u.ProfileImage)
.AsSingleQuery()
.FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");

View File

@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.Migrations.Stages;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.IO;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Removes orphaned extras (items with OwnerId pointing to non-existent items).
/// Must run before EF migrations that add FK constraints on OwnerId.
/// </summary>
[JellyfinMigration("2026-01-13T23:00:00", nameof(CleanupOrphanedExtras), Stage = JellyfinMigrationStageTypes.CoreInitialisation)]
[JellyfinMigrationBackup(JellyfinDb = true)]
public class CleanupOrphanedExtras : IAsyncMigrationRoutine
{
private readonly IStartupLogger<CleanupOrphanedExtras> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="CleanupOrphanedExtras"/> class.
/// </summary>
/// <param name="logger">The startup logger.</param>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="itemRepository">The item repository.</param>
/// <param name="itemCountService">The item count service.</param>
/// <param name="channelManager">The channel manager.</param>
/// <param name="recordingsManager">The recordings manager.</param>
/// <param name="mediaSourceManager">The media source manager.</param>
/// <param name="mediaSegmentManager">The media segments manager.</param>
/// <param name="configurationManager">The configuration manager.</param>
/// <param name="fileSystem">The file system.</param>
public CleanupOrphanedExtras(
IStartupLogger<CleanupOrphanedExtras> logger,
IDbContextFactory<JellyfinDbContext> dbContextFactory,
ILibraryManager libraryManager,
IItemRepository itemRepository,
IItemCountService itemCountService,
IChannelManager channelManager,
IRecordingsManager recordingsManager,
IMediaSourceManager mediaSourceManager,
IMediaSegmentManager mediaSegmentManager,
IServerConfigurationManager configurationManager,
IFileSystem fileSystem)
{
_logger = logger;
_dbContextFactory = dbContextFactory;
_libraryManager = libraryManager;
BaseItem.LibraryManager ??= libraryManager;
BaseItem.ItemRepository ??= itemRepository;
BaseItem.ItemCountService ??= itemCountService;
BaseItem.ChannelManager ??= channelManager;
BaseItem.MediaSourceManager ??= mediaSourceManager;
BaseItem.MediaSegmentManager ??= mediaSegmentManager;
BaseItem.ConfigurationManager ??= configurationManager;
BaseItem.FileSystem ??= fileSystem;
Video.RecordingsManager ??= recordingsManager;
}
/// <inheritdoc/>
public async Task PerformAsync(CancellationToken cancellationToken)
{
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
var orphanedItemIds = await context.BaseItems
.Where(b => b.OwnerId.HasValue && !b.OwnerId.Value.Equals(Guid.Empty))
.Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value)))
.Select(b => b.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (orphanedItemIds.Count == 0)
{
_logger.LogInformation("No orphaned extras found, skipping migration.");
return;
}
_logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count);
// Batch-resolve items for metadata path cleanup, then delete all at once
var itemsToDelete = new List<BaseItem>();
foreach (var itemId in orphanedItemIds)
{
var item = _libraryManager.GetItemById(itemId);
if (item is not null)
{
itemsToDelete.Add(item);
}
}
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete);
_logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count);
}
}
}

View File

@@ -1,10 +1,6 @@
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Persistence;
@@ -23,16 +19,19 @@ namespace Jellyfin.Server.Migrations.Routines
#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ILogger<FixAudioData> _logger;
private readonly IServerApplicationPaths _applicationPaths;
private readonly IItemRepository _itemRepository;
private readonly IItemCountService _countService;
private readonly IItemPersistenceService _persistenceService;
public FixAudioData(
IServerApplicationPaths applicationPaths,
ILoggerFactory loggerFactory,
IItemRepository itemRepository)
IItemRepository itemRepository,
IItemCountService countService,
IItemPersistenceService persistenceService)
{
_applicationPaths = applicationPaths;
_itemRepository = itemRepository;
_countService = countService;
_persistenceService = persistenceService;
_logger = loggerFactory.CreateLogger<FixAudioData>();
}
@@ -41,7 +40,7 @@ namespace Jellyfin.Server.Migrations.Routines
{
_logger.LogInformation("Backfilling audio lyrics data to database.");
var startIndex = 0;
var records = _itemRepository.GetCount(new InternalItemsQuery
var records = _countService.GetCount(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Audio],
});
@@ -68,7 +67,7 @@ namespace Jellyfin.Server.Migrations.Routines
}
}
_itemRepository.SaveItems(results, CancellationToken.None);
_persistenceService.SaveItems(results, CancellationToken.None);
startIndex += results.Count;
_logger.LogInformation("Backfilled data for {UpdatedRecords} of {TotalRecords} audio records", startIndex, records);
}

View File

@@ -0,0 +1,341 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Fixes incorrect OwnerId relationships where video/movie items are children of other video/movie items.
/// These are alternate versions (4K vs 1080p) that were incorrectly linked as parent-child relationships
/// by the auto-merge logic. Only legitimate extras (trailers, behind-the-scenes) should have OwnerId set.
/// Also removes duplicate database entries for the same file path.
/// </summary>
[JellyfinMigration("2026-01-15T12:00:00", nameof(FixIncorrectOwnerIdRelationships))]
[JellyfinMigrationBackup(JellyfinDb = true)]
public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine
{
private readonly IStartupLogger<FixIncorrectOwnerIdRelationships> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly ILibraryManager _libraryManager;
private readonly IItemPersistenceService _persistenceService;
/// <summary>
/// Initializes a new instance of the <see cref="FixIncorrectOwnerIdRelationships"/> class.
/// </summary>
/// <param name="logger">The startup logger.</param>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="persistenceService">The item persistence service.</param>
public FixIncorrectOwnerIdRelationships(
IStartupLogger<FixIncorrectOwnerIdRelationships> logger,
IDbContextFactory<JellyfinDbContext> dbContextFactory,
ILibraryManager libraryManager,
IItemPersistenceService persistenceService)
{
_logger = logger;
_dbContextFactory = dbContextFactory;
_libraryManager = libraryManager;
_persistenceService = persistenceService;
}
/// <inheritdoc/>
public async Task PerformAsync(CancellationToken cancellationToken)
{
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
// Step 1: Find and remove duplicate database entries (same Path, different IDs)
await RemoveDuplicateItemsAsync(context, cancellationToken).ConfigureAwait(false);
// Step 2: Clear incorrect OwnerId for video/movie items that are children of other video/movie items
await ClearIncorrectOwnerIdsAsync(context, cancellationToken).ConfigureAwait(false);
// Step 3: Reassign orphaned extras to correct parents
await ReassignOrphanedExtrasAsync(context, cancellationToken).ConfigureAwait(false);
// Step 4: Populate PrimaryVersionId for alternate version children
await PopulatePrimaryVersionIdAsync(context, cancellationToken).ConfigureAwait(false);
}
}
private async Task RemoveDuplicateItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
{
// Find all paths that have duplicate entries
var duplicatePaths = await context.BaseItems
.Where(b => b.Path != null)
.GroupBy(b => b.Path)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (duplicatePaths.Count == 0)
{
_logger.LogInformation("No duplicate items found, skipping duplicate removal.");
return;
}
_logger.LogInformation("Found {Count} paths with duplicate database entries", duplicatePaths.Count);
// Collect all duplicate IDs to delete in one batch
var allIdsToDelete = new List<Guid>();
const int progressLogStep = 500;
var processedPaths = 0;
foreach (var path in duplicatePaths)
{
cancellationToken.ThrowIfCancellationRequested();
if (processedPaths > 0 && processedPaths % progressLogStep == 0)
{
_logger.LogInformation("Resolving duplicates: {Processed}/{Total} paths", processedPaths, duplicatePaths.Count);
}
processedPaths++;
// Get all items with this path
var itemsWithPath = await context.BaseItems
.Where(b => b.Path == path)
.Select(b => new
{
b.Id,
b.Type,
b.DateCreated,
HasOwnedExtras = context.BaseItems.Any(c => c.OwnerId.HasValue && c.OwnerId.Value.Equals(b.Id)),
HasDirectChildren = context.BaseItems.Any(c => c.ParentId.HasValue && c.ParentId.Value.Equals(b.Id))
})
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (itemsWithPath.Count <= 1)
{
continue;
}
// Keep the item that has direct children, then owned extras, then prefer non-Folder types, then newest
var itemToKeep = itemsWithPath
.OrderByDescending(i => i.HasDirectChildren)
.ThenByDescending(i => i.HasOwnedExtras)
.ThenByDescending(i => i.Type != "MediaBrowser.Controller.Entities.Folder")
.ThenByDescending(i => i.DateCreated)
.First();
if (itemToKeep is null)
{
continue;
}
allIdsToDelete.AddRange(itemsWithPath.Where(i => !i.Id.Equals(itemToKeep.Id)).Select(i => i.Id));
}
if (allIdsToDelete.Count > 0)
{
// Batch-resolve items for metadata path cleanup, then delete all at once
var itemsToDelete = allIdsToDelete
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
// Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
}
_logger.LogInformation("Successfully removed {Count} duplicate database entries", allIdsToDelete.Count);
}
private async Task ClearIncorrectOwnerIdsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
{
// Find video/movie items with incorrect OwnerId (ExtraType is NULL or 0, pointing to another video/movie)
var incorrectChildrenWithParent = await context.BaseItems
.Where(b => b.OwnerId.HasValue
&& (b.ExtraType == null || b.ExtraType == 0)
&& (b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Movies.Movie"))
.Where(b => context.BaseItems.Any(parent =>
parent.Id.Equals(b.OwnerId!.Value)
&& (parent.Type == "MediaBrowser.Controller.Entities.Video" || parent.Type == "MediaBrowser.Controller.Entities.Movies.Movie")))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
// Also find orphaned items (parent doesn't exist)
var orphanedChildren = await context.BaseItems
.Where(b => b.OwnerId.HasValue
&& (b.ExtraType == null || b.ExtraType == 0)
&& (b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Movies.Movie"))
.Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value)))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var totalIncorrect = incorrectChildrenWithParent.Count + orphanedChildren.Count;
if (totalIncorrect == 0)
{
_logger.LogInformation("No items with incorrect OwnerId found, skipping OwnerId cleanup.");
return;
}
_logger.LogInformation(
"Found {Count} video/movie items with incorrect OwnerId relationships ({WithParent} with parent, {Orphaned} orphaned)",
totalIncorrect,
incorrectChildrenWithParent.Count,
orphanedChildren.Count);
// Clear OwnerId for all incorrect items
var allIncorrectItems = incorrectChildrenWithParent.Concat(orphanedChildren).ToList();
foreach (var item in allIncorrectItems)
{
item.OwnerId = null;
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Successfully cleared OwnerId for {Count} items", totalIncorrect);
}
private async Task ReassignOrphanedExtrasAsync(JellyfinDbContext context, CancellationToken cancellationToken)
{
// Find extras whose parent was deleted during duplicate removal
var orphanedExtras = await context.BaseItems
.Where(b => b.ExtraType != null && b.ExtraType != 0 && b.OwnerId.HasValue)
.Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value)))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (orphanedExtras.Count == 0)
{
_logger.LogInformation("No orphaned extras found, skipping reassignment.");
return;
}
_logger.LogInformation("Found {Count} orphaned extras to reassign", orphanedExtras.Count);
const int extraProgressLogStep = 500;
// Build a lookup of directory -> first video/movie item for parent resolution
var extraDirectories = orphanedExtras
.Where(e => !string.IsNullOrEmpty(e.Path))
.Select(e => System.IO.Path.GetDirectoryName(e.Path))
.Where(d => !string.IsNullOrEmpty(d))
.Distinct()
.ToList();
// Load all potential parent video/movies with paths in one query
var videoTypes = new[]
{
"MediaBrowser.Controller.Entities.Video",
"MediaBrowser.Controller.Entities.Movies.Movie"
};
var potentialParents = await context.BaseItems
.Where(b => b.Path != null && videoTypes.Contains(b.Type))
.Select(b => new { b.Id, b.Path })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
// Build directory -> parent ID mapping
var dirToParent = new Dictionary<string, Guid>();
foreach (var dir in extraDirectories)
{
var parent = potentialParents
.Where(p => p.Path!.StartsWith(dir!, StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p.Id)
.FirstOrDefault();
if (parent is not null)
{
dirToParent[dir!] = parent.Id;
}
}
var reassignedCount = 0;
var processedExtras = 0;
foreach (var extra in orphanedExtras)
{
if (processedExtras > 0 && processedExtras % extraProgressLogStep == 0)
{
_logger.LogInformation("Reassigning orphaned extras: {Processed}/{Total}", processedExtras, orphanedExtras.Count);
}
processedExtras++;
if (string.IsNullOrEmpty(extra.Path))
{
continue;
}
var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path);
if (!string.IsNullOrEmpty(extraDirectory) && dirToParent.TryGetValue(extraDirectory, out var parentId))
{
extra.OwnerId = parentId;
reassignedCount++;
}
else
{
extra.OwnerId = null;
}
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Successfully reassigned {Count} orphaned extras", reassignedCount);
}
private async Task PopulatePrimaryVersionIdAsync(JellyfinDbContext context, CancellationToken cancellationToken)
{
// Find all alternate version relationships where child's PrimaryVersionId is not set
// ChildType 2 = LocalAlternateVersion, ChildType 3 = LinkedAlternateVersion
var alternateVersionLinks = await context.LinkedChildren
.Where(lc => (lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion
|| lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion))
.Join(
context.BaseItems,
lc => lc.ChildId,
item => item.Id,
(lc, item) => new { lc.ParentId, lc.ChildId, item.PrimaryVersionId })
.Where(x => !x.PrimaryVersionId.HasValue || !x.PrimaryVersionId.Value.Equals(x.ParentId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (alternateVersionLinks.Count == 0)
{
_logger.LogInformation("No alternate version items need PrimaryVersionId population, skipping.");
return;
}
_logger.LogInformation("Found {Count} alternate version items that need PrimaryVersionId populated", alternateVersionLinks.Count);
// Batch-load all child items in a single query
var childIds = alternateVersionLinks.Select(l => l.ChildId).Distinct().ToList();
var childItems = await context.BaseItems
.WhereOneOrMany(childIds, b => b.Id)
.ToDictionaryAsync(b => b.Id, cancellationToken)
.ConfigureAwait(false);
var updatedCount = 0;
const int linkProgressLogStep = 1000;
var processedLinks = 0;
foreach (var link in alternateVersionLinks)
{
if (processedLinks > 0 && processedLinks % linkProgressLogStep == 0)
{
_logger.LogInformation("Populating PrimaryVersionId: {Processed}/{Total} links", processedLinks, alternateVersionLinks.Count);
}
processedLinks++;
if (childItems.TryGetValue(link.ChildId, out var childItem))
{
childItem.PrimaryVersionId = link.ParentId;
updatedCount++;
}
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Successfully populated PrimaryVersionId for {Count} alternate version items", updatedCount);
}
}

View File

@@ -1106,9 +1106,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
entity.OriginalTitle = originalTitle;
}
if (reader.TryGetString(index++, out var primaryVersionId))
if (reader.TryGetString(index++, out var primaryVersionId) && Guid.TryParse(primaryVersionId, out var primaryVersionGuid))
{
entity.PrimaryVersionId = primaryVersionId;
entity.PrimaryVersionId = primaryVersionGuid;
}
if (reader.TryReadDateTime(index++, out var dateLastMediaAdded))
@@ -1210,10 +1210,8 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
entity.ProductionLocations = productionLocations;
}
if (reader.TryGetString(index++, out var extraIds))
{
entity.ExtraIds = extraIds;
}
// Skip ExtraIds column (removed - extras are now tracked via OwnerId relationship)
index++;
if (reader.TryGetInt32(index++, out var totalBitrate))
{
@@ -1250,9 +1248,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
entity.ShowId = showId;
}
if (reader.TryGetString(index++, out var ownerId))
if (reader.TryGetString(index++, out var ownerId) && Guid.TryParse(ownerId, out var ownerIdGuid))
{
entity.OwnerId = ownerId;
entity.OwnerId = ownerIdGuid;
}
if (reader.TryGetString(index++, out var mediaType))

View File

@@ -0,0 +1,589 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migrates LinkedChildren data from JSON Data column to the LinkedChildren table.
/// </summary>
[JellyfinMigration("2026-01-13T12:00:00", nameof(MigrateLinkedChildren))]
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
{
private readonly ILogger<MigrateLinkedChildren> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly ILibraryManager _libraryManager;
private readonly IServerApplicationHost _appHost;
private readonly IServerApplicationPaths _appPaths;
public MigrateLinkedChildren(
ILoggerFactory loggerFactory,
IDbContextFactory<JellyfinDbContext> dbProvider,
ILibraryManager libraryManager,
IServerApplicationHost appHost,
IServerApplicationPaths appPaths)
{
_logger = loggerFactory.CreateLogger<MigrateLinkedChildren>();
_dbProvider = dbProvider;
_libraryManager = libraryManager;
_appHost = appHost;
_appPaths = appPaths;
}
/// <inheritdoc/>
public void Perform()
{
using var context = _dbProvider.CreateDbContext();
var containerTypes = new[]
{
"MediaBrowser.Controller.Entities.Movies.BoxSet",
"MediaBrowser.Controller.Playlists.Playlist",
"MediaBrowser.Controller.Entities.CollectionFolder"
};
var videoTypes = new[]
{
"MediaBrowser.Controller.Entities.Video",
"MediaBrowser.Controller.Entities.Movies.Movie",
"MediaBrowser.Controller.Entities.TV.Episode"
};
var itemsWithData = context.BaseItems
.Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type)))
.Select(b => new { b.Id, b.Data, b.Type })
.ToList();
_logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count);
var pathToIdMap = context.BaseItems
.Where(b => b.Path != null)
.Select(b => new { b.Id, b.Path })
.GroupBy(b => b.Path!)
.ToDictionary(g => g.Key, g => g.First().Id);
var linkedChildrenToAdd = new List<LinkedChildEntity>();
var processedCount = 0;
const int progressLogStep = 1000;
var totalItems = itemsWithData.Count;
foreach (var item in itemsWithData)
{
if (string.IsNullOrEmpty(item.Data))
{
continue;
}
if (processedCount > 0 && processedCount % progressLogStep == 0)
{
_logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItems);
}
try
{
using var doc = JsonDocument.Parse(item.Data);
var isVideo = videoTypes.Contains(item.Type);
// Handle Video alternate versions
if (isVideo)
{
ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd);
}
// Handle LinkedChildren (for containers and other items)
if (!doc.RootElement.TryGetProperty("LinkedChildren", out var linkedChildrenElement) || linkedChildrenElement.ValueKind != JsonValueKind.Array)
{
processedCount++;
continue;
}
var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist";
var sortOrder = 0;
foreach (var childElement in linkedChildrenElement.EnumerateArray())
{
Guid? childId = null;
if (childElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null)
{
var itemIdStr = itemIdProp.GetString();
if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
{
childId = parsedId;
}
}
if (!childId.HasValue || childId.Value.IsEmpty())
{
if (childElement.TryGetProperty("Path", out var pathProp))
{
var path = pathProp.GetString();
if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
{
childId = resolvedId;
}
}
}
if (!childId.HasValue || childId.Value.IsEmpty())
{
if (childElement.TryGetProperty("LibraryItemId", out var libIdProp))
{
var libIdStr = libIdProp.GetString();
if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
{
childId = parsedLibId;
}
}
}
if (!childId.HasValue || childId.Value.IsEmpty())
{
continue;
}
var childType = LinkedChildType.Manual;
if (childElement.TryGetProperty("Type", out var typeProp))
{
if (typeProp.ValueKind == JsonValueKind.Number)
{
childType = (LinkedChildType)typeProp.GetInt32();
}
else if (typeProp.ValueKind == JsonValueKind.String)
{
var typeStr = typeProp.GetString();
if (Enum.TryParse<LinkedChildType>(typeStr, out var parsedType))
{
childType = parsedType;
}
}
}
linkedChildrenToAdd.Add(new LinkedChildEntity
{
ParentId = item.Id,
ChildId = childId.Value,
ChildType = childType,
SortOrder = isPlaylist ? sortOrder : null
});
sortOrder++;
}
processedCount++;
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse JSON for item {ItemId}", item.Id);
}
}
if (linkedChildrenToAdd.Count > 0)
{
_logger.LogInformation("Inserting {Count} LinkedChildren records.", linkedChildrenToAdd.Count);
var existingKeys = context.LinkedChildren
.Select(lc => new { lc.ParentId, lc.ChildId })
.ToHashSet();
var toInsert = linkedChildrenToAdd
.Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId }))
.ToList();
if (toInsert.Count > 0)
{
// Deduplicate by composite key (ParentId, ChildId)
// Priority: LocalAlternateVersion > LinkedAlternateVersion > Other
toInsert = toInsert
.OrderBy(lc => lc.ChildType switch
{
LinkedChildType.LocalAlternateVersion => 0,
LinkedChildType.LinkedAlternateVersion => 1,
_ => 2
})
.DistinctBy(lc => new { lc.ParentId, lc.ChildId })
.ToList();
var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList();
var existingChildIds = context.BaseItems
.WhereOneOrMany(childIds, b => b.Id)
.Select(b => b.Id)
.ToHashSet();
toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList();
context.LinkedChildren.AddRange(toInsert);
context.SaveChanges();
_logger.LogInformation("Successfully inserted {Count} LinkedChildren records.", toInsert.Count);
}
else
{
_logger.LogInformation("All LinkedChildren records already exist, nothing to insert.");
}
}
else
{
_logger.LogInformation("No LinkedChildren data found to migrate.");
}
_logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount);
CleanupWrongTypeAlternateVersions(context);
CleanupOrphanedAlternateVersionBaseItems(context);
CleanupItemsFromDeletedLibraries(context);
CleanupStaleFileEntries(context);
CleanupOrphanedLinkedChildren(context);
}
private void CleanupWrongTypeAlternateVersions(JellyfinDbContext context)
{
_logger.LogInformation("Cleaning up alternate version items with wrong type...");
// Find all LocalAlternateVersion relationships where the child is a generic Video
// but the parent is a more specific type (like Movie).
// Since IDs are computed from type + path, just updating the Type column would break ID lookups.
// Instead, delete them and let the runtime recreate them with the correct type during the next library scan.
var wrongTypeChildIds = context.LinkedChildren
.Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion)
.Join(
context.BaseItems,
lc => lc.ParentId,
parent => parent.Id,
(lc, parent) => new { lc.ChildId, ParentType = parent.Type })
.Join(
context.BaseItems,
x => x.ChildId,
child => child.Id,
(x, child) => new { x.ChildId, x.ParentType, ChildType = child.Type })
.Where(x => x.ChildType != x.ParentType)
.Select(x => x.ChildId)
.Distinct()
.ToList();
if (wrongTypeChildIds.Count == 0)
{
_logger.LogInformation("No wrong-type alternate version items found.");
return;
}
_logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count);
var itemsToDelete = wrongTypeChildIds
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
_logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", itemsToDelete.Count);
}
private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context)
{
_logger.LogInformation("Starting cleanup of orphaned alternate version BaseItems...");
// Find BaseItems that have OwnerId set (they belonged to another item) and are not extras,
// but no LinkedChild entry references them — meaning they're orphaned alternate versions.
// This happens when a version file is renamed: the old BaseItem remains in the DB
// with a stale OwnerId but nothing links to it anymore.
var orphanedVersionIds = context.BaseItems
.Where(b => b.OwnerId.HasValue && b.ExtraType == null)
.Where(b => !context.LinkedChildren.Any(lc => lc.ChildId.Equals(b.Id)))
.Select(b => b.Id)
.ToList();
if (orphanedVersionIds.Count == 0)
{
_logger.LogInformation("No orphaned alternate version BaseItems found.");
return;
}
_logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count);
var itemsToDelete = orphanedVersionIds
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
_logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", itemsToDelete.Count);
}
private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context)
{
_logger.LogInformation("Starting cleanup of items from deleted libraries...");
// Find BaseItems whose TopParentId points to a library (collection folder) that no longer exists.
// This happens when a library is removed but the scan didn't fully clean up all items under it.
var orphanedIds = context.BaseItems
.Where(b => b.TopParentId.HasValue)
.Where(b => !context.BaseItems.Any(lib => lib.Id.Equals(b.TopParentId!.Value)))
.Select(b => b.Id)
.ToList();
if (orphanedIds.Count == 0)
{
_logger.LogInformation("No items from deleted libraries found.");
return;
}
_logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count);
var itemsToDelete = orphanedIds
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
_logger.LogInformation("Removed {Count} items from deleted libraries.", itemsToDelete.Count);
}
private void CleanupStaleFileEntries(JellyfinDbContext context)
{
_logger.LogInformation("Starting cleanup of items with missing files...");
// Get all library media locations and partition into accessible vs inaccessible.
// This mirrors the scanner's safeguard: if a library root is inaccessible
// (e.g. NAS offline), we skip items under it to avoid false deletions.
var virtualFolders = _libraryManager.GetVirtualFolders();
var accessiblePaths = new List<string>();
var inaccessiblePaths = new List<string>();
foreach (var folder in virtualFolders)
{
foreach (var location in folder.Locations)
{
if (Directory.Exists(location) && Directory.EnumerateFileSystemEntries(location).Any())
{
accessiblePaths.Add(location);
}
else
{
inaccessiblePaths.Add(location);
_logger.LogWarning(
"Library location {Path} is inaccessible or empty, skipping file existence checks for items under this path.",
location);
}
}
}
var allLibraryPaths = accessiblePaths.Concat(inaccessiblePaths).ToList();
// Get all non-folder, non-virtual items with paths from the DB
var itemsWithPaths = context.BaseItems
.Where(b => b.Path != null && b.Path != string.Empty)
.Where(b => !b.IsFolder && !b.IsVirtualItem)
.Select(b => new { b.Id, b.Path })
.ToList();
var internalMetadataPath = _appPaths.InternalMetadataPath;
var staleIds = new List<Guid>();
foreach (var item in itemsWithPaths)
{
// Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths
var path = _appHost.ExpandVirtualPath(item.Path!);
// Skip items stored under internal metadata (images, subtitles, trickplay, etc.)
if (path.StartsWith(internalMetadataPath, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (accessiblePaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
{
// Item is under an accessible library location — check if it still exists
// Directory check covers BDMV/DVD items whose Path points to a folder
if (!File.Exists(path) && !Directory.Exists(path))
{
staleIds.Add(item.Id);
}
}
else if (!allLibraryPaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
{
// Item is not under ANY library location (accessible or not) —
// it's orphaned from all libraries (e.g. media path was removed from config)
staleIds.Add(item.Id);
}
// Otherwise: item is under an inaccessible location — skip (storage may be offline)
}
if (staleIds.Count == 0)
{
_logger.LogInformation("No stale items found.");
return;
}
_logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count);
var itemsToDelete = staleIds
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
_logger.LogInformation("Removed {Count} stale items.", itemsToDelete.Count);
}
private void CleanupOrphanedLinkedChildren(JellyfinDbContext context)
{
_logger.LogInformation("Starting cleanup of orphaned LinkedChildren records...");
// Find all LinkedChildren where the ChildId doesn't exist in BaseItems
var orphanedLinkedChildren = context.LinkedChildren
.Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ChildId)))
.ToList();
if (orphanedLinkedChildren.Count == 0)
{
_logger.LogInformation("No orphaned LinkedChildren found.");
return;
}
_logger.LogInformation("Found {Count} orphaned LinkedChildren records to remove.", orphanedLinkedChildren.Count);
var orphanedByParent = context.LinkedChildren
.Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ParentId)))
.ToList();
if (orphanedByParent.Count > 0)
{
_logger.LogInformation("Found {Count} LinkedChildren with non-existent parent.", orphanedByParent.Count);
orphanedLinkedChildren.AddRange(orphanedByParent);
}
// Remove all orphaned records
var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList();
context.LinkedChildren.RemoveRange(distinctOrphaned);
context.SaveChanges();
_logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count);
}
private void ProcessVideoAlternateVersions(
JsonElement root,
Guid parentId,
Dictionary<string, Guid> pathToIdMap,
List<LinkedChildEntity> linkedChildrenToAdd)
{
int sortOrder = 0;
if (root.TryGetProperty("LocalAlternateVersions", out var localAlternateVersionsElement)
&& localAlternateVersionsElement.ValueKind == JsonValueKind.Array)
{
foreach (var pathElement in localAlternateVersionsElement.EnumerateArray())
{
if (pathElement.ValueKind != JsonValueKind.String)
{
continue;
}
var path = pathElement.GetString();
if (string.IsNullOrEmpty(path))
{
continue;
}
// Try to resolve the path to an ItemId
if (pathToIdMap.TryGetValue(path, out var childId))
{
linkedChildrenToAdd.Add(new LinkedChildEntity
{
ParentId = parentId,
ChildId = childId,
ChildType = LinkedChildType.LocalAlternateVersion,
SortOrder = sortOrder++
});
_logger.LogDebug(
"Migrating LocalAlternateVersion: Parent={ParentId}, Child={ChildId}, Path={Path}",
parentId,
childId,
path);
}
else
{
_logger.LogWarning(
"Could not resolve LocalAlternateVersion path to ItemId: {Path} for parent {ParentId}",
path,
parentId);
}
}
}
if (root.TryGetProperty("LinkedAlternateVersions", out var linkedAlternateVersionsElement)
&& linkedAlternateVersionsElement.ValueKind == JsonValueKind.Array)
{
foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray())
{
Guid? childId = null;
// Try to get ItemId
if (linkedChildElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null)
{
var itemIdStr = itemIdProp.GetString();
if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
{
childId = parsedId;
}
}
// Try to get from Path if ItemId not available
if (!childId.HasValue || childId.Value.IsEmpty())
{
if (linkedChildElement.TryGetProperty("Path", out var pathProp))
{
var path = pathProp.GetString();
if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
{
childId = resolvedId;
}
}
}
// Try LibraryItemId as fallback
if (!childId.HasValue || childId.Value.IsEmpty())
{
if (linkedChildElement.TryGetProperty("LibraryItemId", out var libIdProp))
{
var libIdStr = libIdProp.GetString();
if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
{
childId = parsedLibId;
}
}
}
if (!childId.HasValue || childId.Value.IsEmpty())
{
_logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId);
continue;
}
linkedChildrenToAdd.Add(new LinkedChildEntity
{
ParentId = parentId,
ChildId = childId.Value,
ChildType = LinkedChildType.LinkedAlternateVersion,
SortOrder = sortOrder++
});
_logger.LogDebug(
"Migrating LinkedAlternateVersion: Parent={ParentId}, Child={ChildId}",
parentId,
childId.Value);
}
}
}
}

View File

@@ -57,7 +57,8 @@ public class MoveTrickplayFiles : IMigrationRoutine
MediaTypes = [MediaType.Video],
SourceTypes = [SourceType.Library],
IsVirtualItem = false,
IsFolder = false
IsFolder = false,
IncludeOwnedItems = true
};
do

View File

@@ -1,13 +1,10 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using Jellyfin.Server.Implementations.Item;
using Jellyfin.Server.ServerSetupApp;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -40,7 +37,7 @@ public class RefreshCleanNames : IAsyncMigrationRoutine
/// <inheritdoc />
public async Task PerformAsync(CancellationToken cancellationToken)
{
const int Limit = 1000;
const int Limit = 10000;
int itemCount = 0;
var sw = Stopwatch.StartNew();
@@ -61,7 +58,7 @@ public class RefreshCleanNames : IAsyncMigrationRoutine
{
try
{
var newCleanName = string.IsNullOrWhiteSpace(item.Name) ? string.Empty : BaseItemRepository.GetCleanValue(item.Name);
var newCleanName = string.IsNullOrWhiteSpace(item.Name) ? string.Empty : item.Name.GetCleanValue();
if (!string.Equals(newCleanName, item.CleanName, StringComparison.Ordinal))
{
_logger.LogDebug(

View File

@@ -45,10 +45,13 @@ internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine
var linkedChildren = playlist.LinkedChildren;
if (linkedChildren.Length > 0)
{
var nullItemChildren = linkedChildren.Where(c => c.ItemId is null);
var deduplicatedChildren = linkedChildren.DistinctBy(c => c.ItemId);
var newLinkedChildren = nullItemChildren.Concat(deduplicatedChildren);
playlist.LinkedChildren = linkedChildren;
var newLinkedChildren = linkedChildren
.Where(c => c.ItemId is null || c.ItemId.Value.Equals(Guid.Empty))
.Concat(linkedChildren
.Where(c => c.ItemId.HasValue && !c.ItemId.Value.Equals(Guid.Empty))
.DistinctBy(c => c.ItemId))
.ToArray();
playlist.LinkedChildren = newLinkedChildren;
playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
_playlistManager.SavePlaylistFile(playlist);
}

View File

@@ -36,8 +36,14 @@ namespace MediaBrowser.Controller.Dto
/// <param name="options">The options.</param>
/// <param name="user">The user.</param>
/// <param name="owner">The owner.</param>
/// <param name="skipVisibilityCheck">Skip redundant visibility check if items are already filtered.</param>
/// <returns>The <see cref="IReadOnlyList{T}"/> of <see cref="BaseItemDto"/>.</returns>
IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null);
IReadOnlyList<BaseItemDto> GetBaseItemDtos(
IReadOnlyList<BaseItem> items,
DtoOptions options,
User? user = null,
BaseItem? owner = null,
bool skipVisibilityCheck = false);
/// <summary>
/// Gets the item by name dto.

View File

@@ -106,7 +106,6 @@ namespace MediaBrowser.Controller.Entities
ImageInfos = Array.Empty<ItemImageInfo>();
ProductionLocations = Array.Empty<string>();
RemoteTrailers = Array.Empty<MediaUrl>();
ExtraIds = Array.Empty<Guid>();
UserData = [];
}
@@ -397,8 +396,6 @@ namespace MediaBrowser.Controller.Entities
public int Height { get; set; }
public Guid[] ExtraIds { get; set; }
/// <summary>
/// Gets the primary image path.
/// </summary>
@@ -491,6 +488,8 @@ namespace MediaBrowser.Controller.Entities
public static IItemRepository ItemRepository { get; set; }
public static IItemCountService ItemCountService { get; set; }
public static IChapterManager ChapterManager { get; set; }
public static IFileSystem FileSystem { get; set; }
@@ -1340,14 +1339,15 @@ namespace MediaBrowser.Controller.Entities
return false;
}
if (GetParents().Any(i => !i.IsVisible(user, true)))
var parents = GetParents().ToList();
if (parents.Any(i => !i.IsVisible(user, true)))
{
return false;
}
if (checkFolders)
{
var topParent = GetParents().LastOrDefault() ?? this;
var topParent = parents.Count > 0 ? parents[^1] : this;
if (string.IsNullOrEmpty(topParent.Path))
{
@@ -1358,8 +1358,27 @@ namespace MediaBrowser.Controller.Entities
if (itemCollectionFolders.Count > 0)
{
var userCollectionFolders = LibraryManager.GetUserRootFolder().GetChildren(user, true).Select(i => i.Id).ToList();
if (!itemCollectionFolders.Any(userCollectionFolders.Contains))
var blockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders);
IEnumerable<Guid> userCollectionFolderIds;
if (blockedMediaFolders.Length > 0)
{
// User has blocked folders - get all library folders and exclude blocked ones
userCollectionFolderIds = LibraryManager.GetUserRootFolder().Children
.Select(i => i.Id)
.Where(id => !blockedMediaFolders.Contains(id));
}
else if (user.HasPermission(PermissionKind.EnableAllFolders))
{
// User can access all folders - no need to filter
return true;
}
else
{
// User has specific enabled folders
userCollectionFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders);
}
if (!itemCollectionFolders.Any(userCollectionFolderIds.Contains))
{
return false;
}
@@ -1401,7 +1420,13 @@ namespace MediaBrowser.Controller.Entities
{
var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray();
var newExtraIds = Array.ConvertAll(extras, x => x.Id);
var extrasChanged = !item.ExtraIds.SequenceEqual(newExtraIds);
var currentExtraIds = LibraryManager.GetItemList(new InternalItemsQuery()
{
OwnerIds = [item.Id]
}).Select(e => e.Id).ToArray();
var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x));
if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh)
{
@@ -1415,16 +1440,15 @@ namespace MediaBrowser.Controller.Entities
var subOptions = new MetadataRefreshOptions(options);
if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty())
{
i.OwnerId = ownerId;
i.ParentId = Guid.Empty;
subOptions.ForceSave = true;
}
i.OwnerId = ownerId;
i.ParentId = Guid.Empty;
return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken);
});
// Cleanup removed extras
var removedExtraIds = item.ExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray();
var removedExtraIds = currentExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray();
if (removedExtraIds.Length > 0)
{
var removedExtras = LibraryManager.GetItemList(new InternalItemsQuery()
@@ -1433,17 +1457,20 @@ namespace MediaBrowser.Controller.Entities
});
foreach (var removedExtra in removedExtras)
{
LibraryManager.DeleteItem(removedExtra, new DeleteOptions()
// Only delete items that are actual extras (have ExtraType set)
// Items with OwnerId but no ExtraType might be alternate versions, not extras
if (removedExtra.ExtraType.HasValue)
{
DeleteFileLocation = false
});
LibraryManager.DeleteItem(removedExtra, new DeleteOptions()
{
DeleteFileLocation = false
});
}
}
}
await Task.WhenAll(tasks).ConfigureAwait(false);
item.ExtraIds = newExtraIds;
return true;
}
@@ -1673,10 +1700,28 @@ namespace MediaBrowser.Controller.Entities
return list.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
private bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck)
protected bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck)
{
var allTags = GetInheritedTags();
if (user.GetPreference(PreferenceKind.BlockedTags).Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase)))
var blockedTags = user.GetPreference(PreferenceKind.BlockedTags);
var allowedTags = user.GetPreference(PreferenceKind.AllowedTags);
if (blockedTags.Length == 0 && allowedTags.Length == 0)
{
return true;
}
// Normalize tags using the same logic as database queries
var normalizedBlockedTags = blockedTags
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t.GetCleanValue())
.ToHashSet(StringComparer.Ordinal);
var normalizedItemTags = GetInheritedTags()
.Select(t => t.GetCleanValue())
.ToHashSet(StringComparer.Ordinal);
// Check blocked tags - item is hidden if it has any blocked tag
if (normalizedBlockedTags.Overlaps(normalizedItemTags))
{
return false;
}
@@ -1687,10 +1732,18 @@ namespace MediaBrowser.Controller.Entities
return true;
}
var allowedTagsPreference = user.GetPreference(PreferenceKind.AllowedTags);
if (!skipAllowedTagsCheck && allowedTagsPreference.Length != 0 && !allowedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase)))
// Check allowed tags - item must have at least one allowed tag
if (!skipAllowedTagsCheck && allowedTags.Length > 0)
{
return false;
var normalizedAllowedTags = allowedTags
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t.GetCleanValue())
.ToHashSet(StringComparer.Ordinal);
if (!normalizedAllowedTags.Overlaps(normalizedItemTags))
{
return false;
}
}
return true;
@@ -1803,10 +1856,23 @@ namespace MediaBrowser.Controller.Entities
return item;
}
#pragma warning disable CS0618 // Type or member is obsolete - fallback for legacy LinkedChild data
private BaseItem FindLinkedChild(LinkedChild info)
{
var path = info.Path;
// First try to find by ItemId (new preferred method)
if (info.ItemId.HasValue && !info.ItemId.Value.Equals(Guid.Empty))
{
var item = LibraryManager.GetItemById(info.ItemId.Value);
if (item is not null)
{
return item;
}
Logger.LogWarning("Unable to find linked item by ItemId {0}", info.ItemId);
}
// Fall back to Path (legacy method)
var path = info.Path;
if (!string.IsNullOrEmpty(path))
{
path = FileSystem.MakeAbsolutePath(ContainingFolderPath, path);
@@ -1821,13 +1887,14 @@ namespace MediaBrowser.Controller.Entities
return itemByPath;
}
// Fall back to LibraryItemId (legacy method)
if (!string.IsNullOrEmpty(info.LibraryItemId))
{
var item = LibraryManager.GetItemById(info.LibraryItemId);
if (item is null)
{
Logger.LogWarning("Unable to find linked item at path {0}", info.Path);
Logger.LogWarning("Unable to find linked item by LibraryItemId {0}", info.LibraryItemId);
}
return item;
@@ -1835,6 +1902,7 @@ namespace MediaBrowser.Controller.Entities
return null;
}
#pragma warning restore CS0618
/// <summary>
/// Adds a studio to the item.
@@ -2415,7 +2483,13 @@ namespace MediaBrowser.Controller.Entities
return path;
}
public virtual void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields)
public virtual void FillUserDataDtoValues(
UserItemDataDto dto,
UserItemData userData,
BaseItemDto itemDto,
User user,
DtoOptions fields,
(int Played, int Total)? precomputedCounts = null)
{
if (RunTimeTicks.HasValue)
{
@@ -2654,10 +2728,11 @@ namespace MediaBrowser.Controller.Entities
/// <returns>An enumerable containing the items.</returns>
public IEnumerable<BaseItem> GetExtras()
{
return ExtraIds
.Select(LibraryManager.GetItemById)
.Where(i => i is not null)
.OrderBy(i => i.SortName);
return LibraryManager.GetItemList(new InternalItemsQuery()
{
OwnerIds = [Id],
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
}
/// <summary>
@@ -2667,11 +2742,12 @@ namespace MediaBrowser.Controller.Entities
/// <returns>An enumerable containing the extras.</returns>
public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes)
{
return ExtraIds
.Select(LibraryManager.GetItemById)
.Where(i => i is not null)
.Where(i => i.ExtraType.HasValue && extraTypes.Contains(i.ExtraType.Value))
.OrderBy(i => i.SortName);
return LibraryManager.GetItemList(new InternalItemsQuery()
{
OwnerIds = [Id],
ExtraTypes = extraTypes.ToArray(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
}
public virtual long GetRunTimeTicksForPlayState()

View File

@@ -44,6 +44,11 @@ namespace MediaBrowser.Controller.Entities
PhysicalFolderIds = Array.Empty<Guid>();
}
/// <summary>
/// Event raised when library options are updated for any collection folder.
/// </summary>
public static event EventHandler<LibraryOptionsUpdatedEventArgs> LibraryOptionsUpdated;
/// <summary>
/// Gets the display preferences id.
/// </summary>
@@ -74,14 +79,27 @@ namespace MediaBrowser.Controller.Entities
public CollectionType? CollectionType { get; set; }
/// <summary>
/// Gets the item's children.
/// Gets or sets the item's children.
/// </summary>
/// <remarks>
/// Our children are actually just references to the ones in the physical root...
/// Setting to null propagates invalidation to physical folders since the getter
/// always delegates to <see cref="GetActualChildren"/> and never reads the backing field.
/// </remarks>
/// <value>The actual children.</value>
[JsonIgnore]
public override IEnumerable<BaseItem> Children => GetActualChildren();
public override IEnumerable<BaseItem> Children
{
get => GetActualChildren();
set
{
// The getter delegates to physical folders, so invalidate their caches.
foreach (var folder in GetPhysicalFolders(true))
{
folder.Children = null;
}
}
}
[JsonIgnore]
public override bool SupportsPeople => false;
@@ -168,6 +186,8 @@ namespace MediaBrowser.Controller.Entities
}
XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path));
LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, options));
}
public static void OnCollectionFolderChange()

View File

@@ -59,6 +59,10 @@ namespace MediaBrowser.Controller.Entities
/// <value><c>true</c> if this instance is root; otherwise, <c>false</c>.</value>
public bool IsRoot { get; set; }
/// <summary>
/// Gets or sets the linked children.
/// </summary>
[JsonIgnore]
public LinkedChild[] LinkedChildren { get; set; }
[JsonIgnore]
@@ -416,6 +420,17 @@ namespace MediaBrowser.Controller.Entities
// Create a list for our validated children
var newItems = new List<BaseItem>();
var actuallyRemoved = new List<BaseItem>();
// Build a reverse path→item lookup for detecting type changes
var currentChildrenByPath = new Dictionary<string, BaseItem>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in currentChildren)
{
if (!string.IsNullOrEmpty(kvp.Value.Path))
{
currentChildrenByPath.TryAdd(kvp.Value.Path, kvp.Value);
}
}
cancellationToken.ThrowIfCancellationRequested();
@@ -443,6 +458,24 @@ namespace MediaBrowser.Controller.Entities
continue;
}
// Check if an existing item occupies the same path with different type/ID
if (!string.IsNullOrEmpty(child.Path)
&& currentChildrenByPath.TryGetValue(child.Path, out var staleItem)
&& !staleItem.Id.Equals(child.Id))
{
Logger.LogInformation(
"Item type changed at {Path}: {OldType} -> {NewType}, removing stale entry",
child.Path,
staleItem.GetType().Name,
child.GetType().Name);
currentChildren.Remove(staleItem.Id);
currentChildrenByPath.Remove(child.Path);
staleItem.SetParent(null);
LibraryManager.DeleteItem(staleItem, new DeleteOptions { DeleteFileLocation = false }, this, false);
actuallyRemoved.Add(staleItem);
}
// Brand new item - needs to be added
child.SetParent(this);
newItems.Add(child);
@@ -452,8 +485,18 @@ namespace MediaBrowser.Controller.Entities
// That's all the new and changed ones - now see if any have been removed and need cleanup
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
var shouldRemove = !IsRoot || allowRemoveRoot;
var actuallyRemoved = new List<BaseItem>();
// If it's an AggregateFolder, don't remove
// Collect replaced primaries for deferred deletion (after CreateItems)
var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>();
// Build a set of paths that are alternate versions of valid children
// These items should not be deleted - they're managed by their primary video
var alternateVersionPaths = validChildren
.OfType<Video>()
.SelectMany(v => v.LocalAlternateVersions ?? [])
.Where(p => !string.IsNullOrEmpty(p))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (shouldRemove && itemsRemoved.Count > 0)
{
foreach (var item in itemsRemoved)
@@ -464,6 +507,40 @@ namespace MediaBrowser.Controller.Entities
continue;
}
// Skip items that are alternate versions of another video
if (item is Video video)
{
// Check if path is in LocalAlternateVersions of any valid child
if (!string.IsNullOrEmpty(item.Path) && alternateVersionPaths.Contains(item.Path))
{
Logger.LogDebug("Item path matches an alternate version, skipping deletion: {Path}", item.Path);
continue;
}
}
// Defer deletion if this primary video is being replaced by a new primary
// that takes over its alternates. Deleting now would trigger premature
// promotion inside DeleteItem and write stale paths to collection NFOs.
if (item is Video primaryVideo
&& !primaryVideo.PrimaryVersionId.HasValue
&& primaryVideo.OwnerId.IsEmpty()
&& (primaryVideo.LocalAlternateVersions ?? []).Any(p => alternateVersionPaths.Contains(p)))
{
var newPrimary = newItems
.OfType<Video>()
.FirstOrDefault(v => (v.LocalAlternateVersions ?? [])
.Any(p => (primaryVideo.LocalAlternateVersions ?? [])
.Any(op => string.Equals(op, p, StringComparison.OrdinalIgnoreCase))));
if (newPrimary is not null)
{
Logger.LogDebug("Deferring deletion of replaced primary: {Path}", item.Path);
replacedPrimaries.Add((primaryVideo, newPrimary));
actuallyRemoved.Add(item);
item.SetParent(null);
continue;
}
}
if (item.IsFileProtocol)
{
Logger.LogDebug("Removed item: {Path}", item.Path);
@@ -480,6 +557,106 @@ namespace MediaBrowser.Controller.Entities
LibraryManager.CreateItems(newItems, this, cancellationToken);
}
// Process deferred replaced-primary deletions now that new primaries exist in DB/cache.
// This avoids the premature promotion that would occur if DeleteItem ran before CreateItems.
foreach (var (oldPrimary, newPrimary) in replacedPrimaries)
{
Logger.LogInformation(
"Processing deferred deletion of replaced primary {OldName} ({OldId}), new primary {NewName} ({NewId})",
oldPrimary.Name,
oldPrimary.Id,
newPrimary.Name,
newPrimary.Id);
// Reroute collection/playlist references from old primary to new primary
await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false);
// Transfer alternates from old primary to new primary
var localAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary).ToHashSet();
var allAlternateIds = localAlternateIds
.Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id))
.Distinct()
.ToList();
foreach (var altId in allAlternateIds)
{
if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id))
{
altVideo.SetPrimaryVersionId(newPrimary.Id);
altVideo.OwnerId = localAlternateIds.Contains(altVideo.Id) ? newPrimary.Id : Guid.Empty;
await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
}
}
// Clear alternate arrays so DeleteItem won't trigger promotion
oldPrimary.LocalAlternateVersions = [];
oldPrimary.LinkedAlternateVersions = [];
// Safe to delete now — no promotion will happen
LibraryManager.DeleteItem(oldPrimary, new DeleteOptions { DeleteFileLocation = false }, this, false);
}
// Demote old primaries that are now alternate versions of newly created primaries.
// This handles the case where a new file is added that becomes the new primary
// (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion).
// Items in replacedPrimaries are excluded (already in actuallyRemoved).
var oldPrimariesToDemote = new List<(Video OldPrimary, Video NewPrimary)>();
foreach (var item in itemsRemoved.Except(actuallyRemoved))
{
if (item is Video video
&& video.OwnerId.IsEmpty()
&& !string.IsNullOrEmpty(item.Path)
&& alternateVersionPaths.Contains(item.Path))
{
var newPrimary = newItems
.OfType<Video>()
.FirstOrDefault(v => (v.LocalAlternateVersions ?? [])
.Any(p => string.Equals(p, item.Path, StringComparison.OrdinalIgnoreCase)));
if (newPrimary is not null)
{
oldPrimariesToDemote.Add((video, newPrimary));
}
}
}
foreach (var (oldPrimary, newPrimary) in oldPrimariesToDemote)
{
Logger.LogInformation(
"Demoting old primary {OldName} ({OldId}) to alternate of new primary {NewName} ({NewId})",
oldPrimary.Name,
oldPrimary.Id,
newPrimary.Name,
newPrimary.Id);
// First: update old primary's alternate items to point to new primary.
// Order matters — update alternates FIRST so they don't get orphan-deleted
// when old primary's arrays are cleared.
var oldAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary)
.Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id))
.Distinct()
.ToList();
foreach (var altId in oldAlternateIds)
{
if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id))
{
altVideo.SetPrimaryVersionId(newPrimary.Id);
altVideo.OwnerId = newPrimary.Id;
await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
}
}
// Then: demote old primary — clear its arrays and set it as alternate of new primary
oldPrimary.LocalAlternateVersions = [];
oldPrimary.LinkedAlternateVersions = [];
oldPrimary.SetPrimaryVersionId(newPrimary.Id);
oldPrimary.OwnerId = newPrimary.Id;
await oldPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
// Re-route playlist/collection references from old primary to new primary
await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false);
}
// After removing items, reattach any detached user data to remaining children
// that share the same user data keys (eg. same episode replaced with a new file).
if (actuallyRemoved.Count > 0)
@@ -716,36 +893,10 @@ namespace MediaBrowser.Controller.Entities
public QueryResult<BaseItem> QueryRecursive(InternalItemsQuery query)
{
var user = query.User;
if (!query.ForceDirect && RequiresPostFiltering(query))
if (!query.ForceDirect && CollapseBoxSetItems(query, this, query.User, ConfigurationManager))
{
IEnumerable<BaseItem> items;
Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager);
var totalCount = 0;
if (query.User is null)
{
items = GetRecursiveChildren(filter);
totalCount = items.Count();
}
else
{
// Save pagination params before clearing them to prevent pagination from happening
// before sorting. PostFilterAndSort will apply pagination after sorting.
var limit = query.Limit;
var startIndex = query.StartIndex;
query.Limit = null;
query.StartIndex = null;
items = GetRecursiveChildren(user, query, out totalCount);
// Restore pagination params so PostFilterAndSort can apply them after sorting
query.Limit = limit;
query.StartIndex = startIndex;
}
return PostFilterAndSort(items, query);
query.CollapseBoxSetItems = true;
SetCollapseBoxSetItemTypes(query);
}
if (this is not UserRootFolder
@@ -755,15 +906,15 @@ namespace MediaBrowser.Controller.Entities
query.Parent = this;
}
if (RequiresPostFiltering2(query))
if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet)
{
return QueryWithPostFiltering2(query);
return QueryWithPostFiltering(query);
}
return LibraryManager.GetItemsResult(query);
}
protected QueryResult<BaseItem> QueryWithPostFiltering2(InternalItemsQuery query)
protected QueryResult<BaseItem> QueryWithPostFiltering(InternalItemsQuery query)
{
var startIndex = query.StartIndex;
var limit = query.Limit;
@@ -809,120 +960,6 @@ namespace MediaBrowser.Controller.Entities
returnItems.ToArray());
}
private bool RequiresPostFiltering2(InternalItemsQuery query)
{
if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet)
{
Logger.LogDebug("Query requires post-filtering due to BoxSet query");
return true;
}
return false;
}
private bool RequiresPostFiltering(InternalItemsQuery query)
{
if (LinkedChildren.Length > 0)
{
if (this is not ICollectionFolder)
{
Logger.LogDebug("{Type}: Query requires post-filtering due to LinkedChildren.", GetType().Name);
return true;
}
}
// Filter by Video3DFormat
if (query.Is3D.HasValue)
{
Logger.LogDebug("Query requires post-filtering due to Is3D");
return true;
}
if (query.HasOfficialRating.HasValue)
{
Logger.LogDebug("Query requires post-filtering due to HasOfficialRating");
return true;
}
if (query.IsPlaceHolder.HasValue)
{
Logger.LogDebug("Query requires post-filtering due to IsPlaceHolder");
return true;
}
if (query.HasSpecialFeature.HasValue)
{
Logger.LogDebug("Query requires post-filtering due to HasSpecialFeature");
return true;
}
if (query.HasSubtitles.HasValue)
{
Logger.LogDebug("Query requires post-filtering due to HasSubtitles");
return true;
}
if (query.HasTrailer.HasValue)
{
Logger.LogDebug("Query requires post-filtering due to HasTrailer");
return true;
}
if (query.HasThemeSong.HasValue)
{
Logger.LogDebug("Query requires post-filtering due to HasThemeSong");
return true;
}
if (query.HasThemeVideo.HasValue)
{
Logger.LogDebug("Query requires post-filtering due to HasThemeVideo");
return true;
}
// Filter by VideoType
if (query.VideoTypes.Length > 0)
{
Logger.LogDebug("Query requires post-filtering due to VideoTypes");
return true;
}
if (CollapseBoxSetItems(query, this, query.User, ConfigurationManager))
{
Logger.LogDebug("Query requires post-filtering due to CollapseBoxSetItems");
return true;
}
if (!query.AdjacentTo.IsNullOrEmpty())
{
Logger.LogDebug("Query requires post-filtering due to AdjacentTo");
return true;
}
if (query.SeriesStatuses.Length > 0)
{
Logger.LogDebug("Query requires post-filtering due to SeriesStatuses");
return true;
}
if (query.AiredDuringSeason.HasValue)
{
Logger.LogDebug("Query requires post-filtering due to AiredDuringSeason");
return true;
}
if (query.IsPlayed.HasValue)
{
if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(BaseItemKind.Series))
{
Logger.LogDebug("Query requires post-filtering due to IsPlayed");
return true;
}
}
return false;
}
private static BaseItem[] SortItemsByRequest(InternalItemsQuery query, IReadOnlyList<BaseItem> items)
{
return items.OrderBy(i => Array.IndexOf(query.ItemIds, i.Id)).ToArray();
@@ -990,14 +1027,12 @@ namespace MediaBrowser.Controller.Entities
var user = query.User;
Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager);
IEnumerable<BaseItem> items;
int totalItemCount = 0;
if (query.User is null)
{
items = Children.Where(filter);
items = UserViewBuilder.Filter(Children, user, query, UserDataManager, LibraryManager);
totalItemCount = items.Count();
}
else
@@ -1012,7 +1047,12 @@ namespace MediaBrowser.Controller.Entities
NameLessThan = query.NameLessThan
};
items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter);
items = UserViewBuilder.Filter(
GetChildren(user, true, out totalItemCount, childQuery),
user,
query,
UserDataManager,
LibraryManager);
}
return PostFilterAndSort(items, query);
@@ -1026,29 +1066,11 @@ namespace MediaBrowser.Controller.Entities
if (user is not null)
{
items = CollapseBoxSetItemsIfNeeded(items, query, this, user, ConfigurationManager, CollectionManager);
}
#pragma warning disable CA1309
if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater))
{
items = items.Where(i => string.Compare(query.NameStartsWithOrGreater, i.SortName, StringComparison.InvariantCultureIgnoreCase) < 1);
}
if (!string.IsNullOrEmpty(query.NameStartsWith))
{
items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase));
}
if (!string.IsNullOrEmpty(query.NameLessThan))
{
items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.InvariantCultureIgnoreCase) == 1);
}
#pragma warning restore CA1309
// This must be the last filter
if (!query.AdjacentTo.IsNullOrEmpty())
{
items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
// After collapse, BoxSets may have replaced items whose names matched the filter
// but the BoxSet's own name may not match. Re-apply name filtering so BoxSets
// appear under the correct letter (e.g. "Jump Street" under J, not under #).
items = ApplyNameFilter(items, query);
}
var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
@@ -1062,6 +1084,26 @@ namespace MediaBrowser.Controller.Entities
return result;
}
private static IEnumerable<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query)
{
if (!string.IsNullOrWhiteSpace(query.NameStartsWith))
{
items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(query.NameStartsWithOrGreater))
{
items = items.Where(i => string.Compare(i.SortName, query.NameStartsWithOrGreater, StringComparison.OrdinalIgnoreCase) >= 0);
}
if (!string.IsNullOrWhiteSpace(query.NameLessThan))
{
items = items.Where(i => string.Compare(i.SortName, query.NameLessThan, StringComparison.OrdinalIgnoreCase) < 0);
}
return items;
}
private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded(
IEnumerable<BaseItem> items,
InternalItemsQuery query,
@@ -1167,6 +1209,33 @@ namespace MediaBrowser.Controller.Entities
return (queryHasMovies || queryHasSeries) && AllowBoxSetCollapsing(query);
}
private void SetCollapseBoxSetItemTypes(InternalItemsQuery query)
{
var config = ConfigurationManager.Configuration;
bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
bool collapseSeries = config.EnableGroupingShowsIntoCollections;
if (collapseMovies && collapseSeries)
{
// Empty means collapse all types
query.CollapseBoxSetItemTypes = [];
return;
}
var types = new List<BaseItemKind>();
if (collapseMovies)
{
types.Add(BaseItemKind.Movie);
}
if (collapseSeries)
{
types.Add(BaseItemKind.Series);
}
query.CollapseBoxSetItemTypes = types.ToArray();
}
private static bool AllowBoxSetCollapsing(InternalItemsQuery request)
{
if (request.IsFavorite.HasValue)
@@ -1418,8 +1487,7 @@ namespace MediaBrowser.Controller.Entities
.Where(e => e.IsVisible(user))
.ToArray();
var realChildren = visibleChildren
.Where(e => query is null || UserViewBuilder.FilterItem(e, query))
var realChildren = UserViewBuilder.Filter(visibleChildren, query.User, query, UserDataManager, LibraryManager)
.ToArray();
var childCount = realChildren.Length;
@@ -1680,11 +1748,13 @@ namespace MediaBrowser.Controller.Entities
if (!string.IsNullOrEmpty(resolvedPath))
{
#pragma warning disable CS0618 // Type or member is obsolete - shortcuts require Path for lazy ItemId resolution
return new LinkedChild
{
Path = resolvedPath,
Type = LinkedChildType.Shortcut
};
#pragma warning restore CS0618
}
Logger.LogError("Error resolving shortcut {0}", i.FullName);
@@ -1712,12 +1782,6 @@ namespace MediaBrowser.Controller.Entities
}
}
foreach (var child in LinkedChildren)
{
// Reset the cached value
child.ItemId = null;
}
return false;
}
@@ -1795,45 +1859,63 @@ namespace MediaBrowser.Controller.Entities
return !IsPlayed(user, userItemData);
}
public override void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields)
public override void FillUserDataDtoValues(
UserItemDataDto dto,
UserItemData userData,
BaseItemDto itemDto,
User user,
DtoOptions fields,
(int Played, int Total)? precomputedCounts = null)
{
if (!SupportsUserDataFromChildren)
{
return;
}
if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount))
if (SupportsPlayedStatus || (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)))
{
itemDto.RecursiveItemCount = GetRecursiveChildCount(user);
}
int playedCount;
int totalCount;
if (SupportsPlayedStatus)
{
var unplayedQueryResult = GetItems(new InternalItemsQuery(user)
if (precomputedCounts.HasValue)
{
Recursive = true,
IsFolder = false,
IsVirtualItem = false,
EnableTotalRecordCount = true,
Limit = 0,
IsPlayed = false,
DtoOptions = new DtoOptions(false)
{
EnableImages = false
}
}).TotalRecordCount;
dto.UnplayedItemCount = unplayedQueryResult;
if (itemDto?.RecursiveItemCount > 0)
{
var unplayedPercentage = ((double)unplayedQueryResult / itemDto.RecursiveItemCount.Value) * 100;
dto.PlayedPercentage = 100 - unplayedPercentage;
dto.Played = dto.PlayedPercentage.Value >= 100;
// Use batch-fetched counts (avoids N+1 queries)
(playedCount, totalCount) = precomputedCounts.Value;
}
else
{
dto.Played = (dto.UnplayedItemCount ?? 0) == 0;
// Fall back to per-item query when no batch data is available
var query = new InternalItemsQuery(user);
if (LinkedChildren.Length > 0)
{
(playedCount, totalCount) = ItemCountService.GetPlayedAndTotalCountFromLinkedChildren(query, Id);
}
else
{
(playedCount, totalCount) = ItemCountService.GetPlayedAndTotalCount(query, Id);
}
}
if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount))
{
itemDto.RecursiveItemCount = totalCount;
}
if (SupportsPlayedStatus)
{
var unplayedCount = totalCount - playedCount;
dto.UnplayedItemCount = unplayedCount;
if (totalCount > 0)
{
dto.PlayedPercentage = playedCount / (double)totalCount * 100;
dto.Played = playedCount >= totalCount;
}
else
{
dto.Played = true;
}
}
}
}

View File

@@ -18,42 +18,45 @@ namespace MediaBrowser.Controller.Entities
{
public InternalItemsQuery()
{
AlbumArtistIds = Array.Empty<Guid>();
AlbumIds = Array.Empty<Guid>();
AncestorIds = Array.Empty<Guid>();
ArtistIds = Array.Empty<Guid>();
BlockUnratedItems = Array.Empty<UnratedItem>();
BoxSetLibraryFolders = Array.Empty<Guid>();
ChannelIds = Array.Empty<Guid>();
ContributingArtistIds = Array.Empty<Guid>();
AlbumArtistIds = [];
AlbumIds = [];
AncestorIds = [];
ArtistIds = [];
BlockUnratedItems = [];
BoxSetLibraryFolders = [];
ChannelIds = [];
ContributingArtistIds = [];
DtoOptions = new DtoOptions();
EnableTotalRecordCount = true;
ExcludeArtistIds = Array.Empty<Guid>();
ExcludeInheritedTags = Array.Empty<string>();
IncludeInheritedTags = Array.Empty<string>();
ExcludeItemIds = Array.Empty<Guid>();
ExcludeItemTypes = Array.Empty<BaseItemKind>();
ExcludeTags = Array.Empty<string>();
GenreIds = Array.Empty<Guid>();
Genres = Array.Empty<string>();
ExcludeArtistIds = [];
ExcludeInheritedTags = [];
IncludeInheritedTags = [];
ExcludeItemIds = [];
ExcludeItemTypes = [];
ExcludeTags = [];
GenreIds = [];
Genres = [];
GroupByPresentationUniqueKey = true;
ImageTypes = Array.Empty<ImageType>();
IncludeItemTypes = Array.Empty<BaseItemKind>();
ItemIds = Array.Empty<Guid>();
MediaTypes = Array.Empty<MediaType>();
OfficialRatings = Array.Empty<string>();
OrderBy = Array.Empty<(ItemSortBy, SortOrder)>();
PersonIds = Array.Empty<Guid>();
PersonTypes = Array.Empty<string>();
PresetViews = Array.Empty<CollectionType?>();
SeriesStatuses = Array.Empty<SeriesStatus>();
SourceTypes = Array.Empty<SourceType>();
StudioIds = Array.Empty<Guid>();
Tags = Array.Empty<string>();
TopParentIds = Array.Empty<Guid>();
TrailerTypes = Array.Empty<TrailerType>();
VideoTypes = Array.Empty<VideoType>();
Years = Array.Empty<int>();
ImageTypes = [];
IncludeItemTypes = [];
ItemIds = [];
OwnerIds = [];
ExtraTypes = [];
MediaTypes = [];
OfficialRatings = [];
OrderBy = [];
OwnerIds = [];
PersonIds = [];
PersonTypes = [];
PresetViews = [];
SeriesStatuses = [];
SourceTypes = [];
StudioIds = [];
Tags = [];
TopParentIds = [];
TrailerTypes = [];
VideoTypes = [];
Years = [];
SkipDeserialization = false;
}
@@ -110,6 +113,12 @@ namespace MediaBrowser.Controller.Entities
public bool? CollapseBoxSetItems { get; set; }
/// <summary>
/// Gets or sets the item types that should be collapsed into box sets.
/// When empty, all types are collapsed. When set, only items of these types are replaced by their parent box set.
/// </summary>
public BaseItemKind[] CollapseBoxSetItemTypes { get; set; } = [];
public string? NameStartsWithOrGreater { get; set; }
public string? NameStartsWith { get; set; }
@@ -134,6 +143,10 @@ namespace MediaBrowser.Controller.Entities
public Guid[] ItemIds { get; set; }
public Guid[] OwnerIds { get; set; }
public ExtraType[] ExtraTypes { get; set; }
public Guid[] ExcludeItemIds { get; set; }
public Guid? AdjacentTo { get; set; }
@@ -348,6 +361,12 @@ namespace MediaBrowser.Controller.Entities
public bool? HasOwnerId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to include items with an OwnerId
/// (additional parts, alternate versions) that are normally excluded from general queries.
/// </summary>
public bool IncludeOwnedItems { get; set; }
public bool? Is4K { get; set; }
public int? MaxHeight { get; set; }
@@ -364,6 +383,8 @@ namespace MediaBrowser.Controller.Entities
public bool SkipDeserialization { get; set; }
public bool IncludeExtras { get; set; }
public void SetUser(User user)
{
var maxRating = user.MaxParentalRatingScore;

View File

@@ -0,0 +1,31 @@
using System;
using MediaBrowser.Model.Configuration;
namespace MediaBrowser.Controller.Entities;
/// <summary>
/// Event arguments for when library options are updated.
/// </summary>
public class LibraryOptionsUpdatedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="LibraryOptionsUpdatedEventArgs"/> class.
/// </summary>
/// <param name="libraryPath">The path of the library whose options were updated.</param>
/// <param name="libraryOptions">The updated library options.</param>
public LibraryOptionsUpdatedEventArgs(string libraryPath, LibraryOptions libraryOptions)
{
LibraryPath = libraryPath;
LibraryOptions = libraryOptions;
}
/// <summary>
/// Gets the path of the library whose options were updated.
/// </summary>
public string LibraryPath { get; }
/// <summary>
/// Gets the updated library options.
/// </summary>
public LibraryOptions LibraryOptions { get; }
}

View File

@@ -3,7 +3,6 @@
#pragma warning disable CS1591
using System;
using System.Globalization;
namespace MediaBrowser.Controller.Entities
{
@@ -13,10 +12,18 @@ namespace MediaBrowser.Controller.Entities
{
}
/// <summary>
/// Gets or sets the path.
/// </summary>
[Obsolete("Use ItemId instead")]
public string Path { get; set; }
public LinkedChildType Type { get; set; }
/// <summary>
/// Gets or sets the library item id.
/// </summary>
[Obsolete("Use ItemId instead")]
public string LibraryItemId { get; set; }
/// <summary>
@@ -28,18 +35,11 @@ namespace MediaBrowser.Controller.Entities
{
ArgumentNullException.ThrowIfNull(item);
var child = new LinkedChild
return new LinkedChild
{
Path = item.Path,
ItemId = item.Id,
Type = LinkedChildType.Manual
};
if (string.IsNullOrEmpty(child.Path))
{
child.LibraryItemId = item.Id.ToString("N", CultureInfo.InvariantCulture);
}
return child;
}
}
}

View File

@@ -19,17 +19,34 @@ namespace MediaBrowser.Controller.Entities
public bool Equals(LinkedChild x, LinkedChild y)
{
if (x.Type == y.Type)
if (x.Type != y.Type)
{
return _fileSystem.AreEqual(x.Path, y.Path);
return false;
}
return false;
// Compare by ItemId first (preferred)
if (x.ItemId.HasValue && y.ItemId.HasValue)
{
return x.ItemId.Value.Equals(y.ItemId.Value);
}
#pragma warning disable CS0618 // Type or member is obsolete - fallback for shortcut/legacy comparison
// Fall back to Path comparison for shortcuts and legacy data
return _fileSystem.AreEqual(x.Path, y.Path);
#pragma warning restore CS0618
}
public int GetHashCode(LinkedChild obj)
{
// Use ItemId for hash if available, otherwise fall back to legacy fields
if (obj.ItemId.HasValue && !obj.ItemId.Value.Equals(Guid.Empty))
{
return HashCode.Combine(obj.ItemId.Value, obj.Type);
}
#pragma warning disable CS0618 // Type or member is obsolete - fallback for shortcut/legacy hashing
return ((obj.Path ?? string.Empty) + (obj.LibraryItemId ?? string.Empty) + obj.Type).GetHashCode(StringComparison.Ordinal);
#pragma warning restore CS0618
}
}
}

View File

@@ -13,6 +13,16 @@ namespace MediaBrowser.Controller.Entities
/// <summary>
/// Shortcut linked child.
/// </summary>
Shortcut = 1
Shortcut = 1,
/// <summary>
/// Local alternate version (same item, different file path).
/// </summary>
LocalAlternateVersion = 2,
/// <summary>
/// Linked alternate version (different item ID).
/// </summary>
LinkedAlternateVersion = 3
}
}

View File

@@ -37,9 +37,7 @@ namespace MediaBrowser.Controller.Entities.Movies
/// <inheritdoc />
[JsonIgnore]
public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
.Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer)
.ToArray();
public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray();
/// <summary>
/// Gets or sets the display order.
@@ -160,25 +158,68 @@ namespace MediaBrowser.Controller.Entities.Movies
return base.IsVisible(user, skipAllowedTagsCheck);
}
if (base.IsVisible(user, skipAllowedTagsCheck))
if (!IsParentalAllowed(user, skipAllowedTagsCheck))
{
if (LinkedChildren.Length == 0)
{
return true;
}
var userLibraryFolderIds = GetLibraryFolderIds(user);
var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds();
if (libraryFolderIds.Length == 0)
{
return true;
}
return userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i));
return false;
}
return false;
if (LinkedChildren.Length == 0)
{
return true;
}
var userLibraryFolderIds = GetLibraryFolderIds(user);
var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds();
if (libraryFolderIds.Length == 0)
{
return true;
}
if (!userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i)))
{
return false;
}
// If user has parental controls, hide the BoxSet when all children are restricted
if (user.MaxParentalRatingScore.HasValue)
{
var linkedItems = GetLinkedChildren();
if (linkedItems.Count > 0 && linkedItems.All(child => !child.IsParentalAllowed(user, true)))
{
return false;
}
}
return true;
}
public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition)
{
if (IsLegacyBoxSet)
{
base.MarkPlayed(user, datePlayed, resetPosition);
return;
}
foreach (var item in GetLinkedChildren(user))
{
item.MarkPlayed(user, datePlayed, resetPosition);
}
}
public override void MarkUnplayed(User user)
{
if (IsLegacyBoxSet)
{
base.MarkUnplayed(user);
return;
}
foreach (var item in GetLinkedChildren(user))
{
item.MarkUnplayed(user);
}
}
public override bool IsVisibleStandalone(User user)

View File

@@ -4,13 +4,15 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Controller.Entities.Movies
{
@@ -28,9 +30,7 @@ namespace MediaBrowser.Controller.Entities.Movies
/// <inheritdoc />
[JsonIgnore]
public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
.Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer)
.ToArray();
public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray();
/// <summary>
/// Gets or sets the name of the TMDb collection.

View File

@@ -28,9 +28,7 @@ namespace MediaBrowser.Controller.Entities.TV
/// <inheritdoc />
[JsonIgnore]
public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
.Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer)
.ToArray();
public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray();
/// <summary>
/// Gets or sets the season in which it aired.

View File

@@ -175,9 +175,7 @@ namespace MediaBrowser.Controller.Entities.TV
var user = query.User;
Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager);
var items = GetEpisodes(user, query.DtoOptions, true).Where(filter);
var items = UserViewBuilder.Filter(GetEpisodes(user, query.DtoOptions, true), user, query, UserDataManager, LibraryManager);
return PostFilterAndSort(items, query);
}

View File

@@ -52,9 +52,7 @@ namespace MediaBrowser.Controller.Entities.TV
/// <inheritdoc />
[JsonIgnore]
public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
.Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer)
.ToArray();
public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray();
/// <summary>
/// Gets or sets the display order.

View File

@@ -16,9 +16,7 @@ using MediaBrowser.Controller.TV;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.Extensions.Logging;
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider;
using Series = MediaBrowser.Controller.Entities.TV.Series;
namespace MediaBrowser.Controller.Entities
{
@@ -140,7 +138,7 @@ namespace MediaBrowser.Controller.Entities
if (query.IncludeItemTypes.Length == 0)
{
query.IncludeItemTypes = new[] { BaseItemKind.Movie };
query.IncludeItemTypes = [BaseItemKind.Movie];
}
return parent.QueryRecursive(query);
@@ -165,7 +163,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
query.IncludeItemTypes = new[] { BaseItemKind.Movie };
query.IncludeItemTypes = [BaseItemKind.Movie];
return _libraryManager.GetItemsResult(query);
}
@@ -176,7 +174,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
query.IncludeItemTypes = new[] { BaseItemKind.Series };
query.IncludeItemTypes = [BaseItemKind.Series];
return _libraryManager.GetItemsResult(query);
}
@@ -187,7 +185,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
query.IncludeItemTypes = new[] { BaseItemKind.Episode };
query.IncludeItemTypes = [BaseItemKind.Episode];
return _libraryManager.GetItemsResult(query);
}
@@ -198,7 +196,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.IncludeItemTypes = new[] { BaseItemKind.Movie };
query.IncludeItemTypes = [BaseItemKind.Movie];
return _libraryManager.GetItemsResult(query);
}
@@ -206,7 +204,7 @@ namespace MediaBrowser.Controller.Entities
private QueryResult<BaseItem> GetMovieCollections(User user, InternalItemsQuery query)
{
query.Parent = null;
query.IncludeItemTypes = new[] { BaseItemKind.BoxSet };
query.IncludeItemTypes = [BaseItemKind.BoxSet];
query.SetUser(user);
query.Recursive = true;
@@ -215,25 +213,25 @@ namespace MediaBrowser.Controller.Entities
private QueryResult<BaseItem> GetMovieLatest(Folder parent, User user, InternalItemsQuery query)
{
query.OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) };
query.OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)];
query.Recursive = true;
query.Parent = parent;
query.SetUser(user);
query.Limit = GetSpecialItemsLimit();
query.IncludeItemTypes = new[] { BaseItemKind.Movie };
query.IncludeItemTypes = [BaseItemKind.Movie];
return ConvertToResult(_libraryManager.GetItemList(query));
}
private QueryResult<BaseItem> GetMovieResume(Folder parent, User user, InternalItemsQuery query)
{
query.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) };
query.OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)];
query.IsResumable = true;
query.Recursive = true;
query.Parent = parent;
query.SetUser(user);
query.Limit = GetSpecialItemsLimit();
query.IncludeItemTypes = new[] { BaseItemKind.Movie };
query.IncludeItemTypes = [BaseItemKind.Movie];
return ConvertToResult(_libraryManager.GetItemList(query));
}
@@ -247,7 +245,7 @@ namespace MediaBrowser.Controller.Entities
{
var genres = parent.QueryRecursive(new InternalItemsQuery(user)
{
IncludeItemTypes = new[] { BaseItemKind.Movie },
IncludeItemTypes = [BaseItemKind.Movie],
Recursive = true,
EnableTotalRecordCount = false
}).Items
@@ -275,10 +273,10 @@ namespace MediaBrowser.Controller.Entities
{
query.Recursive = true;
query.Parent = queryParent;
query.GenreIds = new[] { displayParent.Id };
query.GenreIds = [displayParent.Id];
query.SetUser(user);
query.IncludeItemTypes = new[] { BaseItemKind.Movie };
query.IncludeItemTypes = [BaseItemKind.Movie];
return _libraryManager.GetItemsResult(query);
}
@@ -292,12 +290,12 @@ namespace MediaBrowser.Controller.Entities
if (query.IncludeItemTypes.Length == 0)
{
query.IncludeItemTypes = new[]
{
query.IncludeItemTypes =
[
BaseItemKind.Series,
BaseItemKind.Season,
BaseItemKind.Episode
};
];
}
return parent.QueryRecursive(query);
@@ -319,12 +317,12 @@ namespace MediaBrowser.Controller.Entities
private QueryResult<BaseItem> GetTvLatest(Folder parent, User user, InternalItemsQuery query)
{
query.OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) };
query.OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)];
query.Recursive = true;
query.Parent = parent;
query.SetUser(user);
query.Limit = GetSpecialItemsLimit();
query.IncludeItemTypes = new[] { BaseItemKind.Episode };
query.IncludeItemTypes = [BaseItemKind.Episode];
query.IsVirtualItem = false;
return ConvertToResult(_libraryManager.GetItemList(query));
@@ -332,7 +330,7 @@ namespace MediaBrowser.Controller.Entities
private QueryResult<BaseItem> GetTvNextUp(Folder parent, InternalItemsQuery query)
{
var parentFolders = GetMediaFolders(parent, query.User, new[] { CollectionType.tvshows });
var parentFolders = GetMediaFolders(parent, query.User, [CollectionType.tvshows]);
var result = _tvSeriesManager.GetNextUp(
new NextUpQuery
@@ -349,13 +347,13 @@ namespace MediaBrowser.Controller.Entities
private QueryResult<BaseItem> GetTvResume(Folder parent, User user, InternalItemsQuery query)
{
query.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) };
query.OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)];
query.IsResumable = true;
query.Recursive = true;
query.Parent = parent;
query.SetUser(user);
query.Limit = GetSpecialItemsLimit();
query.IncludeItemTypes = new[] { BaseItemKind.Episode };
query.IncludeItemTypes = [BaseItemKind.Episode];
return ConvertToResult(_libraryManager.GetItemList(query));
}
@@ -366,7 +364,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.IncludeItemTypes = new[] { BaseItemKind.Series };
query.IncludeItemTypes = [BaseItemKind.Series];
return _libraryManager.GetItemsResult(query);
}
@@ -375,7 +373,7 @@ namespace MediaBrowser.Controller.Entities
{
var genres = parent.QueryRecursive(new InternalItemsQuery(user)
{
IncludeItemTypes = new[] { BaseItemKind.Series },
IncludeItemTypes = [BaseItemKind.Series],
Recursive = true,
EnableTotalRecordCount = false
}).Items
@@ -403,10 +401,10 @@ namespace MediaBrowser.Controller.Entities
{
query.Recursive = true;
query.Parent = queryParent;
query.GenreIds = new[] { displayParent.Id };
query.GenreIds = [displayParent.Id];
query.SetUser(user);
query.IncludeItemTypes = new[] { BaseItemKind.Series };
query.IncludeItemTypes = [BaseItemKind.Series];
return _libraryManager.GetItemsResult(query);
}
@@ -416,29 +414,54 @@ namespace MediaBrowser.Controller.Entities
InternalItemsQuery query)
where T : BaseItem
{
items = items.Where(i => Filter(i, query.User, query, _userDataManager, _libraryManager));
var filtered = Filter(items, query.User, query, _userDataManager, _libraryManager);
return PostFilterAndSort(items, null, query, _libraryManager);
return SortAndPage(filtered, null, query, _libraryManager);
}
public static bool FilterItem(BaseItem item, InternalItemsQuery query)
{
return Filter(item, query.User, query, BaseItem.UserDataManager, BaseItem.LibraryManager);
}
public static QueryResult<BaseItem> PostFilterAndSort(
/// <summary>
/// Batch-aware filter that applies per-item checks.
/// </summary>
/// <param name="items">The items to filter.</param>
/// <param name="user">The user for filtering context.</param>
/// <param name="query">The query parameters.</param>
/// <param name="userDataManager">The user data manager.</param>
/// <param name="libraryManager">The library manager.</param>
/// <returns>The filtered items.</returns>
public static IEnumerable<BaseItem> Filter(
IEnumerable<BaseItem> items,
int? totalRecordLimit,
User user,
InternalItemsQuery query,
IUserDataManager userDataManager,
ILibraryManager libraryManager)
{
// This must be the last filter
if (!query.AdjacentTo.IsNullOrEmpty())
var filtered = items.Where(i => Filter(i, user, query, userDataManager, libraryManager));
if (query.IsPlayed.HasValue && user is not null)
{
items = FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
var itemList = filtered.ToList();
var folderIds = itemList.OfType<Folder>().Select(f => f.Id).ToList();
if (folderIds.Count > 0)
{
var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
var isPlayedValue = query.IsPlayed.Value;
return itemList.Where(i =>
{
if (i.IsFolder && counts.TryGetValue(i.Id, out var c))
{
return (c.Total > 0 && c.Played == c.Total) == isPlayedValue;
}
return true;
});
}
return itemList;
}
return SortAndPage(items, totalRecordLimit, query, libraryManager);
return filtered;
}
public static QueryResult<BaseItem> SortAndPage(
@@ -470,7 +493,12 @@ namespace MediaBrowser.Controller.Entities
itemsArray);
}
public static bool Filter(BaseItem item, User user, InternalItemsQuery query, IUserDataManager userDataManager, ILibraryManager libraryManager)
private static bool Filter(
BaseItem item,
User user,
InternalItemsQuery query,
IUserDataManager userDataManager,
ILibraryManager libraryManager)
{
if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase))
{
@@ -558,36 +586,18 @@ namespace MediaBrowser.Controller.Entities
if (query.IsPlayed.HasValue)
{
userData ??= userDataManager.GetUserData(user, item);
if (item.IsPlayed(user, userData) != query.IsPlayed.Value)
// Folder.IsPlayed() hits the DB per-item (N+1 queries).
// Folders are batch-filtered by the collection Filter() overload.
if (!item.IsFolder)
{
return false;
userData ??= userDataManager.GetUserData(user, item);
if (item.IsPlayed(user, userData) != query.IsPlayed.Value)
{
return false;
}
}
}
// Filter by Video3DFormat
if (query.Is3D.HasValue)
{
var val = query.Is3D.Value;
var video = item as Video;
if (video is null || val != video.Video3DFormat.HasValue)
{
return false;
}
}
/*
* fuck - fix this
if (query.IsHD.HasValue)
{
if (item.IsHD != query.IsHD.Value)
{
return false;
}
}
*/
if (query.IsLocked.HasValue)
{
var val = query.IsLocked.Value;
@@ -645,68 +655,6 @@ namespace MediaBrowser.Controller.Entities
}
}
if (query.HasOfficialRating.HasValue)
{
var filterValue = query.HasOfficialRating.Value;
var hasValue = !string.IsNullOrEmpty(item.OfficialRating);
if (hasValue != filterValue)
{
return false;
}
}
if (query.IsPlaceHolder.HasValue)
{
var filterValue = query.IsPlaceHolder.Value;
var isPlaceHolder = false;
if (item is ISupportsPlaceHolders hasPlaceHolder)
{
isPlaceHolder = hasPlaceHolder.IsPlaceHolder;
}
if (isPlaceHolder != filterValue)
{
return false;
}
}
if (query.HasSpecialFeature.HasValue)
{
var filterValue = query.HasSpecialFeature.Value;
if (item is IHasSpecialFeatures movie)
{
var ok = filterValue
? movie.SpecialFeatureIds.Count > 0
: movie.SpecialFeatureIds.Count == 0;
if (!ok)
{
return false;
}
}
else
{
return false;
}
}
if (query.HasSubtitles.HasValue)
{
var val = query.HasSubtitles.Value;
var video = item as Video;
if (video is null || val != video.HasSubtitles)
{
return false;
}
}
if (query.HasParentalRating.HasValue)
{
var val = query.HasParentalRating.Value;
@@ -734,66 +682,12 @@ namespace MediaBrowser.Controller.Entities
}
}
if (query.HasTrailer.HasValue)
{
var val = query.HasTrailer.Value;
var trailerCount = 0;
if (item is IHasTrailers hasTrailers)
{
trailerCount = hasTrailers.GetTrailerCount();
}
var ok = val ? trailerCount > 0 : trailerCount == 0;
if (!ok)
{
return false;
}
}
if (query.HasThemeSong.HasValue)
{
var filterValue = query.HasThemeSong.Value;
var themeCount = item.GetThemeSongs(user).Count;
var ok = filterValue ? themeCount > 0 : themeCount == 0;
if (!ok)
{
return false;
}
}
if (query.HasThemeVideo.HasValue)
{
var filterValue = query.HasThemeVideo.Value;
var themeCount = item.GetThemeVideos(user).Count;
var ok = filterValue ? themeCount > 0 : themeCount == 0;
if (!ok)
{
return false;
}
}
// Apply genre filter
if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
// Filter by VideoType
if (query.VideoTypes.Length > 0)
{
var video = item as Video;
if (video is null || !query.VideoTypes.Contains(video.VideoType))
{
return false;
}
}
if (query.ImageTypes.Length > 0 && !query.ImageTypes.Any(item.HasImage))
{
return false;
@@ -912,30 +806,6 @@ namespace MediaBrowser.Controller.Entities
}
}
if (query.SeriesStatuses.Length > 0)
{
var ok = new[] { item }.OfType<Series>().Any(p => p.Status.HasValue && query.SeriesStatuses.Contains(p.Status.Value));
if (!ok)
{
return false;
}
}
if (query.AiredDuringSeason.HasValue)
{
var episode = item as Episode;
if (episode is null)
{
return false;
}
if (!Series.FilterEpisodesBySeason(new[] { episode }, query.AiredDuringSeason.Value, true).Any())
{
return false;
}
}
if (query.ExcludeItemIds.Contains(item.Id))
{
return false;
@@ -989,7 +859,7 @@ namespace MediaBrowser.Controller.Entities
return GetMediaFolders(user, viewTypes);
}
return new BaseItem[] { parent };
return [parent];
}
private UserView GetUserViewWithName(CollectionType? type, string sortName, BaseItem parent)

View File

@@ -19,6 +19,7 @@ using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Controller.Entities
{
@@ -40,7 +41,7 @@ namespace MediaBrowser.Controller.Entities
}
[JsonIgnore]
public string PrimaryVersionId { get; set; }
public Guid? PrimaryVersionId { get; set; }
public string[] AdditionalParts { get; set; }
@@ -160,7 +161,7 @@ namespace MediaBrowser.Controller.Entities
public bool IsStacked => AdditionalParts.Length > 0;
[JsonIgnore]
public override bool HasLocalAlternateVersions => LocalAlternateVersions.Length > 0;
public override bool HasLocalAlternateVersions => LibraryManager.GetLocalAlternateVersionIds(this).Any();
public static IRecordingsManager RecordingsManager { get; set; }
@@ -253,14 +254,17 @@ namespace MediaBrowser.Controller.Entities
private int GetMediaSourceCount(HashSet<Guid> callstack = null)
{
callstack ??= new();
if (!string.IsNullOrEmpty(PrimaryVersionId))
if (PrimaryVersionId.HasValue)
{
var item = LibraryManager.GetItemById(PrimaryVersionId);
var item = LibraryManager.GetItemById(PrimaryVersionId.Value);
if (item is Video video)
{
if (callstack.Contains(video.Id))
{
return video.LinkedAlternateVersions.Length + video.LocalAlternateVersions.Length + 1;
// Count alternate versions using LibraryManager
var linkedCount = LibraryManager.GetLinkedAlternateVersions(video).Count();
var localCount = LibraryManager.GetLocalAlternateVersionIds(video).Count();
return linkedCount + localCount + 1;
}
callstack.Add(video.Id);
@@ -268,7 +272,10 @@ namespace MediaBrowser.Controller.Entities
}
}
return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1;
// Count alternate versions using LibraryManager
var linkedVersionCount = LibraryManager.GetLinkedAlternateVersions(this).Count();
var localVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count();
return linkedVersionCount + localVersionCount + 1;
}
public override List<string> GetUserDataKeys()
@@ -310,25 +317,17 @@ namespace MediaBrowser.Controller.Entities
return list;
}
public void SetPrimaryVersionId(string id)
public void SetPrimaryVersionId(Guid? id)
{
if (string.IsNullOrEmpty(id))
{
PrimaryVersionId = null;
}
else
{
PrimaryVersionId = id;
}
PrimaryVersionId = id;
PresentationUniqueKey = CreatePresentationUniqueKey();
}
public override string CreatePresentationUniqueKey()
{
if (!string.IsNullOrEmpty(PrimaryVersionId))
if (PrimaryVersionId.HasValue)
{
return PrimaryVersionId;
return PrimaryVersionId.Value.ToString("N", CultureInfo.InvariantCulture);
}
return base.CreatePresentationUniqueKey();
@@ -364,11 +363,6 @@ namespace MediaBrowser.Controller.Entities
return AdditionalParts.Select(i => LibraryManager.GetNewItemId(i, typeof(Video)));
}
public IEnumerable<Guid> GetLocalAlternateVersionIds()
{
return LocalAlternateVersions.Select(i => LibraryManager.GetNewItemId(i, typeof(Video)));
}
private string GetUserDataKey(string providerId)
{
var key = providerId + "-" + ExtraType.ToString().ToLowerInvariant();
@@ -382,15 +376,6 @@ namespace MediaBrowser.Controller.Entities
return key;
}
public IEnumerable<Video> GetLinkedAlternateVersions()
{
return LinkedAlternateVersions
.Select(GetLinkedChild)
.Where(i => i is not null)
.OfType<Video>()
.OrderBy(i => i.SortName);
}
/// <summary>
/// Gets the additional parts.
/// </summary>
@@ -436,10 +421,21 @@ namespace MediaBrowser.Controller.Entities
{
var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
// Clean up LocalAlternateVersions - remove paths that no longer exist
if (LocalAlternateVersions.Length > 0)
{
var validPaths = LocalAlternateVersions.Where(FileSystem.FileExists).ToArray();
if (validPaths.Length != LocalAlternateVersions.Length)
{
LocalAlternateVersions = validPaths;
hasChanges = true;
}
}
if (IsStacked)
{
var tasks = AdditionalParts
.Select(i => RefreshMetadataForOwnedVideo(options, true, i, cancellationToken));
.Select(i => RefreshMetadataForOwnedVideo(options, true, i, typeof(Video), cancellationToken));
await Task.WhenAll(tasks).ConfigureAwait(false);
}
@@ -449,30 +445,134 @@ namespace MediaBrowser.Controller.Entities
// The additional parts won't have additional parts themselves
if (IsFileProtocol && SupportsOwnedItems)
{
if (!IsStacked)
// Check if LinkedChildren are in sync before processing
var existingVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count();
var tasks = LocalAlternateVersions
.Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken));
await Task.WhenAll(tasks).ConfigureAwait(false);
if (existingVersionCount != LocalAlternateVersions.Length)
{
RefreshLinkedAlternateVersions();
var tasks = LocalAlternateVersions
.Select(i => RefreshMetadataForOwnedVideo(options, false, i, cancellationToken));
await Task.WhenAll(tasks).ConfigureAwait(false);
hasChanges = true;
}
}
return hasChanges;
}
private void RefreshLinkedAlternateVersions()
private async Task RefreshMetadataForVersions(
MetadataRefreshOptions options,
bool copyTitleMetadata,
string path,
CancellationToken cancellationToken)
{
foreach (var child in LinkedAlternateVersions)
// Ensure the alternate version exists with the correct type (e.g. Movie, not Video)
// before refreshing. This must happen here rather than in RefreshMetadataForOwnedVideo
// because that method is also used for stacked parts which should keep their resolved type.
var id = LibraryManager.GetNewItemId(path, GetType());
if (LibraryManager.GetItemById(id) is not Video && FileSystem.FileExists(path))
{
// Reset the cached value
if (child.ItemId.IsNullOrEmpty())
var parentFolder = GetParent() as Folder;
var collectionType = LibraryManager.GetContentType(this);
var altVideo = LibraryManager.ResolveAlternateVersion(path, GetType(), parentFolder, collectionType);
if (altVideo is not null)
{
child.ItemId = null;
altVideo.OwnerId = Id;
altVideo.SetPrimaryVersionId(Id);
LibraryManager.CreateItem(altVideo, GetParent());
}
}
await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false);
// Create LinkedChild entry for this local alternate version
// This ensures the relationship exists in the database even if the alternate version
// was created after the primary video was first saved
if (LibraryManager.GetItemById(id) is Video video)
{
LibraryManager.UpsertLinkedChild(Id, video.Id, LinkedChildType.LocalAlternateVersion);
// Ensure PrimaryVersionId is set for existing alternate versions that may not have it
if (!video.PrimaryVersionId.HasValue)
{
video.SetPrimaryVersionId(Id);
await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
}
}
}
private new Task RefreshMetadataForOwnedVideo(
MetadataRefreshOptions options,
bool copyTitleMetadata,
string path,
CancellationToken cancellationToken)
=> RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, GetType(), cancellationToken);
private async Task RefreshMetadataForOwnedVideo(
MetadataRefreshOptions options,
bool copyTitleMetadata,
string path,
Type itemType,
CancellationToken cancellationToken)
{
var newOptions = new MetadataRefreshOptions(options)
{
SearchResult = null
};
var id = LibraryManager.GetNewItemId(path, itemType);
// Check if the file still exists
if (!FileSystem.FileExists(path))
{
// File was removed - clean up any orphaned database entry
if (LibraryManager.GetItemById(id) is Video orphanedVideo && orphanedVideo.OwnerId.Equals(Id))
{
Logger.LogInformation("Owned video file no longer exists, removing orphaned item: {Path}", path);
LibraryManager.DeleteItem(orphanedVideo, new DeleteOptions { DeleteFileLocation = false });
}
return;
}
if (LibraryManager.GetItemById(id) is not Video video)
{
var parentFolder = GetParent() as Folder;
var collectionType = LibraryManager.GetContentType(this);
video = LibraryManager.ResolvePath(
FileSystem.GetFileSystemInfo(path),
parentFolder,
collectionType: collectionType) as Video;
if (video is null)
{
return;
}
// Ensure parts use the expected base type (e.g. Video, not Movie)
if (video.GetType() != itemType && Activator.CreateInstance(itemType) is Video correctVideo)
{
correctVideo.Path = video.Path;
correctVideo.Name = video.Name;
correctVideo.VideoType = video.VideoType;
correctVideo.ProductionYear = video.ProductionYear;
correctVideo.ExtraType = video.ExtraType;
video = correctVideo;
}
video.Id = id;
video.OwnerId = Id;
LibraryManager.CreateItem(video, parentFolder);
newOptions.ForceSave = true;
}
if (video.OwnerId.IsEmpty())
{
video.OwnerId = Id;
}
await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -480,7 +580,7 @@ namespace MediaBrowser.Controller.Entities
{
await base.UpdateToRepositoryAsync(updateReason, cancellationToken).ConfigureAwait(false);
var localAlternates = GetLocalAlternateVersionIds()
var localAlternates = LibraryManager.GetLocalAlternateVersionIds(this)
.Select(i => LibraryManager.GetItemById(i))
.Where(i => i is not null);
@@ -537,22 +637,24 @@ namespace MediaBrowser.Controller.Entities
(this, MediaSourceType.Default)
};
list.AddRange(GetLinkedAlternateVersions().Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
list.AddRange(
LibraryManager.GetLinkedAlternateVersions(this)
.Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
if (!string.IsNullOrEmpty(PrimaryVersionId))
if (PrimaryVersionId.HasValue)
{
if (LibraryManager.GetItemById(PrimaryVersionId) is Video primary)
if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary)
{
var existingIds = list.Select(i => i.Item1.Id).ToList();
list.Add((primary, MediaSourceType.Grouping));
list.AddRange(primary.GetLinkedAlternateVersions().Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
}
}
var localAlternates = list
.SelectMany(i =>
{
return i.Item1 is Video video ? video.GetLocalAlternateVersionIds() : Enumerable.Empty<Guid>();
return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>();
})
.Select(LibraryManager.GetItemById)
.Where(i => i is not null)

View File

@@ -20,6 +20,7 @@ using MediaBrowser.Model.IO;
using MediaBrowser.Model.Querying;
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
using Genre = MediaBrowser.Controller.Entities.Genre;
using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
using Person = MediaBrowser.Controller.Entities.Person;
namespace MediaBrowser.Controller.Library
@@ -58,11 +59,29 @@ namespace MediaBrowser.Controller.Library
/// <param name="fileInfo">The file information.</param>
/// <param name="parent">The parent.</param>
/// <param name="directoryService">An instance of <see cref="IDirectoryService"/>.</param>
/// <param name="collectionType">The collection type of the library containing this item.</param>
/// <returns>BaseItem.</returns>
BaseItem? ResolvePath(
FileSystemMetadata fileInfo,
Folder? parent = null,
IDirectoryService? directoryService = null);
IDirectoryService? directoryService = null,
CollectionType? collectionType = null);
/// <summary>
/// Resolves a video file as an alternate version of a primary video, ensuring the result
/// has the same concrete type as the primary (e.g. Movie instead of generic Video).
/// Also cleans up any existing item with the wrong type from a previous scan.
/// </summary>
/// <param name="path">The file path of the alternate version.</param>
/// <param name="expectedVideoType">The expected concrete type (same as the primary video).</param>
/// <param name="parent">The parent folder.</param>
/// <param name="collectionType">The collection type of the library.</param>
/// <returns>A correctly-typed Video, or null if resolution fails.</returns>
Video? ResolveAlternateVersion(
string path,
Type expectedVideoType,
Folder? parent,
CollectionType? collectionType);
/// <summary>
/// Resolves a set of files into a list of BaseItem.
@@ -213,6 +232,30 @@ namespace MediaBrowser.Controller.Library
/// <returns>IEnumerable{System.String}.</returns>
Task<IEnumerable<Video>> GetIntros(BaseItem item, User user);
/// <summary>
/// Gets the IDs of local alternate versions for a video.
/// Local alternate versions are alternate quality versions at different file paths.
/// </summary>
/// <param name="video">The video item.</param>
/// <returns>Enumerable of alternate version item IDs.</returns>
IEnumerable<Guid> GetLocalAlternateVersionIds(Video video);
/// <summary>
/// Gets the linked alternate versions for a video.
/// Linked alternate versions are different items representing the same content (e.g., Director's Cut).
/// </summary>
/// <param name="video">The video item.</param>
/// <returns>Enumerable of linked Video items.</returns>
IEnumerable<Video> GetLinkedAlternateVersions(Video video);
/// <summary>
/// Creates or updates a LinkedChild entry linking a parent to a child item.
/// </summary>
/// <param name="parentId">The parent item ID.</param>
/// <param name="childId">The child item ID.</param>
/// <param name="childType">The type of linked child relationship.</param>
void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType);
/// <summary>
/// Adds the parts.
/// </summary>
@@ -348,8 +391,9 @@ namespace MediaBrowser.Controller.Library
/// Deletes items that are not having any children like Actors.
/// </summary>
/// <param name="items">Items to delete.</param>
/// <param name="deleteSourceFiles">Whether to delete source media files on disk. Defaults to false.</param>
/// <remarks>In comparison to <see cref="DeleteItem(BaseItem, DeleteOptions, BaseItem, bool)"/> this method skips a lot of steps assuming there are no children to recusively delete nor does it define the special handling for channels and alike.</remarks>
public void DeleteItemsUnsafeFast(IEnumerable<BaseItem> items);
public void DeleteItemsUnsafeFast(IReadOnlyCollection<BaseItem> items, bool deleteSourceFiles = false);
/// <summary>
/// Deletes the item.
@@ -600,6 +644,20 @@ namespace MediaBrowser.Controller.Library
/// <returns>List of series presentation keys.</returns>
IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents, DateTime dateCutoff);
/// <summary>
/// Gets next up episodes for multiple series in a single batched query.
/// </summary>
/// <param name="query">The query filter.</param>
/// <param name="seriesKeys">The series presentation unique keys to query.</param>
/// <param name="includeSpecials">Whether to include specials for aired episode order sorting.</param>
/// <param name="includeWatchedForRewatching">Whether to include watched episodes for rewatching mode.</param>
/// <returns>A dictionary mapping series key to batch result.</returns>
IReadOnlyDictionary<string, MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult> GetNextUpEpisodesBatch(
InternalItemsQuery query,
IReadOnlyList<string> seriesKeys,
bool includeSpecials,
bool includeWatchedForRewatching);
/// <summary>
/// Gets the items result.
/// </summary>
@@ -649,6 +707,42 @@ namespace MediaBrowser.Controller.Library
ItemCounts GetItemCounts(InternalItemsQuery query);
/// <summary>
/// Gets item counts for a "by-name" item using an optimized query path.
/// </summary>
/// <param name="kind">The kind of the name item.</param>
/// <param name="id">The ID of the name item.</param>
/// <param name="relatedItemKinds">The item kinds to count.</param>
/// <param name="user">The user for access filtering.</param>
/// <returns>The item counts grouped by type.</returns>
ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, User? user);
/// <summary>
/// Batch-fetches child counts for multiple parent folders.
/// Returns the count of immediate children (non-recursive) for each parent.
/// </summary>
/// <param name="parentIds">The list of parent folder IDs.</param>
/// <param name="userId">The user ID for access filtering.</param>
/// <returns>Dictionary mapping parent ID to child count.</returns>
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
/// <summary>
/// Batch-fetches played and total counts for multiple folder items.
/// Avoids N+1 queries when building DTOs for lists of folder items.
/// </summary>
/// <param name="folderIds">The list of folder item IDs.</param>
/// <param name="user">The user for access filtering and played status.</param>
/// <returns>Dictionary mapping folder ID to (Played count, Total count).</returns>
Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user);
/// <summary>
/// Configures the query with user access settings including TopParentIds for library access.
/// Call this before passing a query to methods that need user access filtering.
/// </summary>
/// <param name="query">The query to configure.</param>
/// <param name="user">The user to configure access for.</param>
void ConfigureUserAccess(InternalItemsQuery query, User user);
Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason);
BaseItem GetParentItem(Guid? parentId, Guid? userId);
@@ -667,5 +761,21 @@ namespace MediaBrowser.Controller.Library
/// <param name="virtualFolderPath">The path to the virtualfolder.</param>
/// <param name="pathInfo">The new virtualfolder.</param>
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo);
/// <summary>
/// Re-routes LinkedChildren references from one child to another.
/// Used when video versions change to maintain playlist/BoxSet integrity.
/// </summary>
/// <param name="fromChildId">The child ID to re-route from.</param>
/// <param name="toChildId">The child ID to re-route to.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task RerouteLinkedChildReferencesAsync(Guid fromChildId, Guid toChildId);
/// <summary>
/// Gets legacy query filters for filtering UI.
/// </summary>
/// <param name="query">The query filter.</param>
/// <returns>Aggregated filter values.</returns>
QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query);
}
}

View File

@@ -54,6 +54,14 @@ namespace MediaBrowser.Controller.Library
/// <returns>User data dto.</returns>
UserItemDataDto? GetUserDataDto(BaseItem item, User user);
/// <summary>
/// Gets user data for multiple items in a single batch operation.
/// </summary>
/// <param name="items">The items to get user data for.</param>
/// <param name="user">The user.</param>
/// <returns>A dictionary mapping item IDs to their user data.</returns>
Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user);
/// <summary>
/// Gets the user data dto.
/// </summary>

View File

@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Provides item counting and played-status query operations.
/// </summary>
public interface IItemCountService
{
/// <summary>
/// Gets the count of items matching the filter.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>The item count.</returns>
int GetCount(InternalItemsQuery filter);
/// <summary>
/// Gets item counts grouped by type.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>The item counts by type.</returns>
ItemCounts GetItemCounts(InternalItemsQuery filter);
/// <summary>
/// Gets item counts for a "by-name" item using an optimized query.
/// </summary>
/// <param name="kind">The kind of the name item.</param>
/// <param name="id">The ID of the name item.</param>
/// <param name="relatedItemKinds">The item kinds to count.</param>
/// <param name="accessFilter">A pre-configured query with user access filtering settings.</param>
/// <returns>The item counts grouped by type.</returns>
ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter);
/// <summary>
/// Gets the count of played items that are descendants of the specified ancestor.
/// </summary>
/// <param name="filter">The query filter containing user access settings.</param>
/// <param name="ancestorId">The ancestor item id.</param>
/// <returns>The count of played descendant items.</returns>
int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId);
/// <summary>
/// Gets the total count of items that are descendants of the specified ancestor.
/// </summary>
/// <param name="filter">The query filter containing user access settings.</param>
/// <param name="ancestorId">The ancestor item id.</param>
/// <returns>The total count of descendant items.</returns>
int GetTotalCount(InternalItemsQuery filter, Guid ancestorId);
/// <summary>
/// Gets both the played count and total count of descendant items.
/// </summary>
/// <param name="filter">The query filter containing user access settings.</param>
/// <param name="ancestorId">The ancestor item id.</param>
/// <returns>A tuple containing (Played count, Total count).</returns>
(int Played, int Total) GetPlayedAndTotalCount(InternalItemsQuery filter, Guid ancestorId);
/// <summary>
/// Gets both the played count and total count from linked children.
/// </summary>
/// <param name="filter">The query filter containing user access settings.</param>
/// <param name="parentId">The parent item id.</param>
/// <returns>A tuple containing (Played count, Total count).</returns>
(int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId);
/// <summary>
/// Batch-fetches played and total counts for multiple folder items.
/// </summary>
/// <param name="folderIds">The list of folder item IDs to get counts for.</param>
/// <param name="user">The user for access filtering and played status.</param>
/// <returns>Dictionary mapping folder ID to (Played count, Total count).</returns>
Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user);
/// <summary>
/// Batch-fetches child counts for multiple parent folders.
/// </summary>
/// <param name="parentIds">The list of parent folder IDs.</param>
/// <param name="userId">The user ID for access filtering.</param>
/// <returns>Dictionary mapping parent ID to child count.</returns>
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Provides item persistence operations (save, delete, update).
/// </summary>
public interface IItemPersistenceService
{
/// <summary>
/// Deletes items by their IDs.
/// </summary>
/// <param name="ids">The IDs to delete.</param>
void DeleteItem(params IReadOnlyList<Guid> ids);
/// <summary>
/// Saves items to the database.
/// </summary>
/// <param name="items">The items to save.</param>
/// <param name="cancellationToken">The cancellation token.</param>
void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken);
/// <summary>
/// Saves image info for an item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default);
/// <summary>
/// Reattaches user data entries to the correct item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
/// <summary>
/// Updates inherited values.
/// </summary>
void UpdateInheritedValues();
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Linq;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Provides shared query-building methods used by extracted item services.
/// Implemented by <c>BaseItemRepository</c>.
/// </summary>
public interface IItemQueryHelpers
{
/// <summary>
/// Translates an <see cref="InternalItemsQuery"/> into EF Core filter expressions.
/// </summary>
/// <param name="baseQuery">The base queryable to filter.</param>
/// <param name="context">The database context.</param>
/// <param name="filter">The query filter.</param>
/// <returns>The filtered queryable.</returns>
IQueryable<BaseItemEntity> TranslateQuery(
IQueryable<BaseItemEntity> baseQuery,
JellyfinDbContext context,
InternalItemsQuery filter);
/// <summary>
/// Prepares a base query for items from the context.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="filter">The query filter.</param>
/// <returns>The prepared queryable.</returns>
IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter);
/// <summary>
/// Applies user access filtering (library access, parental controls, tags) to a query.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="baseQuery">The base queryable to filter.</param>
/// <param name="filter">The query filter containing access settings.</param>
/// <returns>The access-filtered queryable.</returns>
IQueryable<BaseItemEntity> ApplyAccessFiltering(
JellyfinDbContext context,
IQueryable<BaseItemEntity> baseQuery,
InternalItemsQuery filter);
/// <summary>
/// Applies navigation property includes to a query based on filter options.
/// </summary>
/// <param name="dbQuery">The queryable to apply navigations to.</param>
/// <param name="filter">The query filter.</param>
/// <returns>The queryable with navigation includes.</returns>
IQueryable<BaseItemEntity> ApplyNavigations(
IQueryable<BaseItemEntity> dbQuery,
InternalItemsQuery filter);
/// <summary>
/// Applies ordering to a query based on filter options.
/// </summary>
/// <param name="query">The queryable to order.</param>
/// <param name="filter">The query filter.</param>
/// <param name="context">The database context.</param>
/// <returns>The ordered queryable.</returns>
IQueryable<BaseItemEntity> ApplyOrder(
IQueryable<BaseItemEntity> query,
InternalItemsQuery filter,
JellyfinDbContext context);
/// <summary>
/// Builds a query for descendants of an ancestor with user access filtering applied.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="filter">The query filter.</param>
/// <param name="ancestorId">The ancestor item ID.</param>
/// <returns>The filtered descendant queryable.</returns>
IQueryable<BaseItemEntity> BuildAccessFilteredDescendantsQuery(
JellyfinDbContext context,
InternalItemsQuery filter,
Guid ancestorId);
/// <summary>
/// Builds an <see cref="IQueryable{Guid}"/> of folder IDs whose descendants are all played
/// for the given user. Composable into outer queries to avoid an extra DB roundtrip.
/// </summary>
/// <param name="context">The database context the resulting query is bound to.</param>
/// <param name="folderIds">A query yielding candidate folder IDs.</param>
/// <param name="user">The user for access filtering and played status.</param>
/// <returns>An <see cref="IQueryable{Guid}"/> of fully-played folder IDs.</returns>
IQueryable<Guid> GetFullyPlayedFolderIdsQuery(
JellyfinDbContext context,
IQueryable<Guid> folderIds,
User user);
/// <summary>
/// Deserializes a <see cref="BaseItemEntity"/> into a <see cref="BaseItem"/>.
/// </summary>
/// <param name="entity">The database entity.</param>
/// <param name="skipDeserialization">Whether to skip JSON deserialization.</param>
/// <returns>The deserialized item, or null.</returns>
BaseItem? DeserializeBaseItem(BaseItemEntity entity, bool skipDeserialization = false);
/// <summary>
/// Prepares a filter query by adjusting limits and virtual item settings.
/// </summary>
/// <param name="query">The query to prepare.</param>
void PrepareFilterQuery(InternalItemsQuery query);
}

View File

@@ -1,15 +1,11 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Querying;
@@ -20,29 +16,6 @@ namespace MediaBrowser.Controller.Persistence;
/// </summary>
public interface IItemRepository
{
/// <summary>
/// Deletes the item.
/// </summary>
/// <param name="ids">The identifier to delete.</param>
void DeleteItem(params IReadOnlyList<Guid> ids);
/// <summary>
/// Saves the items.
/// </summary>
/// <param name="items">The items.</param>
/// <param name="cancellationToken">The cancellation token.</param>
void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken);
Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default);
/// <summary>
/// Reattaches the user data to the item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous reattachment operation.</returns>
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
/// <summary>
/// Retrieves the item.
/// </summary>
@@ -79,43 +52,6 @@ public interface IItemRepository
/// <returns>List&lt;BaseItem&gt;.</returns>
IReadOnlyList<BaseItem> GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType);
/// <summary>
/// Gets the list of series presentation keys for next up.
/// </summary>
/// <param name="filter">The query.</param>
/// <param name="dateCutoff">The minimum date for a series to have been most recently watched.</param>
/// <returns>The list of keys.</returns>
IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff);
/// <summary>
/// Updates the inherited values.
/// </summary>
void UpdateInheritedValues();
int GetCount(InternalItemsQuery filter);
ItemCounts GetItemCounts(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery filter);
IReadOnlyList<string> GetMusicGenreNames();
IReadOnlyList<string> GetStudioNames();
IReadOnlyList<string> GetGenreNames();
IReadOnlyList<string> GetAllArtistNames();
/// <summary>
/// Checks if an item has been persisted to the database.
/// </summary>
@@ -124,18 +60,84 @@ public interface IItemRepository
Task<bool> ItemExistsAsync(Guid id);
/// <summary>
/// Gets a value indicating wherever all children of the requested Id has been played.
/// Gets genres with item counts.
/// </summary>
/// <param name="user">The userdata to check against.</param>
/// <param name="id">The Top id to check.</param>
/// <param name="recursive">Whever the check should be done recursive. Warning expensive operation.</param>
/// <returns>A value indicating whever all children has been played.</returns>
bool GetIsPlayed(User user, Guid id, bool recursive);
/// <param name="filter">The query filter.</param>
/// <returns>The genres and their item counts.</returns>
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter);
/// <summary>
/// Gets all artist matches from the db.
/// Gets music genres with item counts.
/// </summary>
/// <param name="artistNames">The names of the artists.</param>
/// <returns>A map of the artist name and the potential matches.</returns>
IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames);
/// <param name="filter">The query filter.</param>
/// <returns>The music genres and their item counts.</returns>
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter);
/// <summary>
/// Gets studios with item counts.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>The studios and their item counts.</returns>
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery filter);
/// <summary>
/// Gets artists with item counts.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>The artists and their item counts.</returns>
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery filter);
/// <summary>
/// Gets album artists with item counts.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>The album artists and their item counts.</returns>
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery filter);
/// <summary>
/// Gets all artists with item counts.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>All artists and their item counts.</returns>
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery filter);
/// <summary>
/// Gets all music genre names.
/// </summary>
/// <returns>The list of music genre names.</returns>
IReadOnlyList<string> GetMusicGenreNames();
/// <summary>
/// Gets all studio names.
/// </summary>
/// <returns>The list of studio names.</returns>
IReadOnlyList<string> GetStudioNames();
/// <summary>
/// Gets all genre names.
/// </summary>
/// <returns>The list of genre names.</returns>
IReadOnlyList<string> GetGenreNames();
/// <summary>
/// Gets all artist names.
/// </summary>
/// <returns>The list of artist names.</returns>
IReadOnlyList<string> GetAllArtistNames();
/// <summary>
/// Gets legacy query filters aggregated from the database.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>Aggregated filter values.</returns>
QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery filter);
/// <summary>
/// Gets whether all children of the requested item have been played.
/// </summary>
/// <param name="user">The user to check against.</param>
/// <param name="id">The top item id to check.</param>
/// <param name="recursive">Whether the check should be done recursively.</param>
/// <returns>A value indicating whether all children have been played.</returns>
bool GetIsPlayed(User user, Guid id, bool recursive);
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Controller.Entities.Audio;
using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Provides linked children query and manipulation operations.
/// </summary>
public interface ILinkedChildrenService
{
/// <summary>
/// Gets the IDs of linked children for the specified parent.
/// </summary>
/// <param name="parentId">The parent item ID.</param>
/// <param name="childType">Optional child type filter.</param>
/// <returns>List of child item IDs.</returns>
IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null);
/// <summary>
/// Gets all artist matches from the database.
/// </summary>
/// <param name="artistNames">The names of the artists.</param>
/// <returns>A map of the artist name and the potential matches.</returns>
IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames);
/// <summary>
/// Gets parent IDs that reference the specified child with LinkedChildType.Manual.
/// </summary>
/// <param name="childId">The child item ID.</param>
/// <returns>List of parent IDs that reference the child.</returns>
IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId);
/// <summary>
/// Updates LinkedChildren references from one child to another.
/// </summary>
/// <param name="fromChildId">The child ID to re-route from.</param>
/// <param name="toChildId">The child ID to re-route to.</param>
/// <returns>List of parent item IDs whose LinkedChildren were modified.</returns>
IReadOnlyList<Guid> RerouteLinkedChildren(Guid fromChildId, Guid toChildId);
/// <summary>
/// Creates or updates a LinkedChild entry.
/// </summary>
/// <param name="parentId">The parent item ID.</param>
/// <param name="childId">The child item ID.</param>
/// <param name="childType">The type of linked child relationship.</param>
void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType);
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Provides next-up episode query operations.
/// </summary>
public interface INextUpService
{
/// <summary>
/// Gets the list of series presentation keys for next up.
/// </summary>
/// <param name="filter">The query.</param>
/// <param name="dateCutoff">The minimum date for a series to have been most recently watched.</param>
/// <returns>The list of keys.</returns>
IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff);
/// <summary>
/// Gets next up episodes for multiple series in a single batched query.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <param name="seriesKeys">The series presentation unique keys to query.</param>
/// <param name="includeSpecials">Whether to include specials.</param>
/// <param name="includeWatchedForRewatching">Whether to include watched episodes for rewatching mode.</param>
/// <returns>A dictionary mapping series key to batch result.</returns>
IReadOnlyDictionary<string, NextUpEpisodeBatchResult> GetNextUpEpisodesBatch(
InternalItemsQuery filter,
IReadOnlyList<string> seriesKeys,
bool includeSpecials,
bool includeWatchedForRewatching);
}

View File

@@ -0,0 +1,38 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Persistence;
/// <summary>
/// Result of a batched NextUp query for a single series.
/// </summary>
public sealed class NextUpEpisodeBatchResult
{
/// <summary>
/// Gets or sets the last watched episode (highest season/episode that is played).
/// </summary>
public BaseItem? LastWatched { get; set; }
/// <summary>
/// Gets or sets the next unwatched episode after the last watched position.
/// </summary>
public BaseItem? NextUp { get; set; }
/// <summary>
/// Gets or sets specials that may air between episodes.
/// Only populated when includeSpecials is true.
/// </summary>
public IReadOnlyList<BaseItem>? Specials { get; set; }
/// <summary>
/// Gets or sets the last watched episode for rewatching mode (most recently played).
/// Only populated when includeWatchedForRewatching is true.
/// </summary>
public BaseItem? LastWatchedForRewatching { get; set; }
/// <summary>
/// Gets or sets the next played episode for rewatching mode.
/// Only populated when includeWatchedForRewatching is true.
/// </summary>
public BaseItem? NextPlayedForRewatching { get; set; }
}

View File

@@ -3,6 +3,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MediaBrowser.Model.IO;
@@ -26,7 +27,20 @@ namespace MediaBrowser.Controller.Providers
public FileSystemMetadata[] GetFileSystemEntries(string path)
{
return _cache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem);
return _cache.GetOrAdd(
path,
static (p, fileSystem) =>
{
try
{
return fileSystem.GetFileSystemEntries(p).ToArray();
}
catch (DirectoryNotFoundException)
{
return [];
}
},
_fileSystem);
}
public List<FileSystemMetadata> GetDirectories(string path)
@@ -98,7 +112,20 @@ namespace MediaBrowser.Controller.Providers
_filePathCache.TryRemove(path, out _);
}
var filePaths = _filePathCache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFilePaths(p).ToList(), _fileSystem);
var filePaths = _filePathCache.GetOrAdd(
path,
static (p, fileSystem) =>
{
try
{
return fileSystem.GetFilePaths(p).ToList();
}
catch (DirectoryNotFoundException)
{
return [];
}
},
_fileSystem);
if (sort)
{

View File

@@ -780,7 +780,8 @@ namespace MediaBrowser.LocalMetadata.Parsers
}
/// <summary>
/// Get linked child.
/// Get linked child from XML. Uses deprecated Path/LibraryItemId properties for backward compatibility
/// with existing XML files. These will be resolved to ItemId when the linked child is accessed.
/// </summary>
/// <param name="reader">The xml reader.</param>
/// <returns>The linked child.</returns>
@@ -791,6 +792,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
reader.MoveToContent();
reader.Read();
#pragma warning disable CS0618 // Type or member is obsolete - reading legacy XML format for backward compatibility
// Loop through each element
while (!reader.EOF && reader.ReadState == ReadState.Interactive)
{
@@ -820,6 +822,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
{
return linkedItem;
}
#pragma warning restore CS0618
return null;
}

View File

@@ -467,41 +467,40 @@ namespace MediaBrowser.LocalMetadata.Savers
}
/// <summary>
/// ADd linked children.
/// Add linked children.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="writer">The xml writer.</param>
/// <param name="pluralNodeName">The plural node name.</param>
/// <param name="singularNodeName">The singular node name.</param>
/// <returns>The task object representing the asynchronous operation.</returns>
private static async Task AddLinkedChildren(Folder item, XmlWriter writer, string pluralNodeName, string singularNodeName)
private async Task AddLinkedChildren(Folder item, XmlWriter writer, string pluralNodeName, string singularNodeName)
{
var items = item.LinkedChildren
var linkedChildren = item.LinkedChildren
.Where(i => i.Type == LinkedChildType.Manual)
.ToList();
if (items.Count == 0)
if (linkedChildren.Count == 0)
{
return;
}
await writer.WriteStartElementAsync(null, pluralNodeName, null).ConfigureAwait(false);
foreach (var link in items)
foreach (var link in linkedChildren)
{
if (!string.IsNullOrWhiteSpace(link.Path) || !string.IsNullOrWhiteSpace(link.LibraryItemId))
// Resolve ItemId to get the item's path for XML portability
string? path = null;
if (link.ItemId.HasValue && !link.ItemId.Value.Equals(Guid.Empty))
{
var linkedItem = LibraryManager.GetItemById(link.ItemId.Value);
path = linkedItem?.Path;
}
if (!string.IsNullOrWhiteSpace(path))
{
await writer.WriteStartElementAsync(null, singularNodeName, null).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(link.Path))
{
await writer.WriteElementStringAsync(null, "Path", null, link.Path).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(link.LibraryItemId))
{
await writer.WriteElementStringAsync(null, "ItemId", null, link.LibraryItemId).ConfigureAwait(false);
}
await writer.WriteElementStringAsync(null, "Path", null, path).ConfigureAwait(false);
await writer.WriteEndElementAsync().ConfigureAwait(false);
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Controller.Configuration;
@@ -69,8 +70,18 @@ public class BoxSetMetadataService : MetadataService<BoxSet, BoxSetInfo>
if (mergeMetadataSettings)
{
// TODO: Change to only replace when currently empty or requested. This is currently not done because the metadata service is not handling attaching collection items based on the provider responses
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray();
// Only merge LinkedChildren from metadata for external collections (not managed by Jellyfin).
// For internal collections, the database LinkedChildren table is the source of truth.
var targetPath = targetItem.Path;
if (!string.IsNullOrEmpty(targetPath)
&& !FileSystem.ContainsSubPath(ServerConfigurationManager.ApplicationPaths.DataPath, targetPath))
{
#pragma warning disable CS0618 // Type or member is obsolete - fallback for legacy path-based dedup
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren)
.DistinctBy(i => i.ItemId.HasValue && !i.ItemId.Value.Equals(Guid.Empty) ? i.ItemId.Value.ToString() : i.Path ?? string.Empty)
.ToArray();
#pragma warning restore CS0618
}
}
}

View File

@@ -31,6 +31,7 @@ using MediaBrowser.Model.Extensions;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Net;
using MediaBrowser.Model.Providers;
using MediaBrowser.Model.Querying;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Book = MediaBrowser.Controller.Entities.Book;
@@ -69,6 +70,13 @@ namespace MediaBrowser.Providers.Manager
o.PoolInitialFill = 1;
});
/// <summary>
/// Cache for ordered metadata providers per library/item type combination.
/// Key: (LibraryPath, ItemTypeName, IncludeDisabled, ForceEnableInternetMetadata).
/// Value: Array of ordered metadata providers (before per-item filtering).
/// </summary>
private readonly ConcurrentDictionary<MetadataProviderCacheKey, IMetadataProvider[]> _metadataProviderCache = new();
private IImageProvider[] _imageProviders = [];
private IMetadataService[] _metadataServices = [];
private IMetadataProvider[] _metadataProviders = [];
@@ -119,6 +127,8 @@ namespace MediaBrowser.Providers.Manager
_lyricManager = lyricManager;
_memoryCache = memoryCache;
_mediaSegmentManager = mediaSegmentManager;
CollectionFolder.LibraryOptionsUpdated += OnLibraryOptionsUpdated;
}
/// <inheritdoc/>
@@ -427,8 +437,37 @@ namespace MediaBrowser.Providers.Manager
where T : BaseItem
{
var globalMetadataOptions = GetMetadataOptions(item);
var libraryPath = GetLibraryPathForItem(item);
return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, false, false);
return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, false, false, libraryPath);
}
/// <summary>
/// Gets metadata providers for the specified item.
/// </summary>
/// <typeparam name="T">The item type.</typeparam>
/// <param name="item">The item.</param>
/// <param name="libraryOptions">The library options.</param>
/// <param name="includeDisabled">Whether to include disabled providers.</param>
/// <returns>The metadata providers.</returns>
public IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions, bool includeDisabled)
where T : BaseItem
{
var globalMetadataOptions = GetMetadataOptions(item);
var libraryPath = GetLibraryPathForItem(item);
return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, includeDisabled, false, libraryPath);
}
private static string GetLibraryPathForItem(BaseItem item)
{
if (item is CollectionFolder collectionFolder)
{
return collectionFolder.Path ?? string.Empty;
}
var topParent = item.GetTopParent();
return topParent?.Path ?? string.Empty;
}
/// <inheritdoc />
@@ -437,15 +476,37 @@ namespace MediaBrowser.Providers.Manager
return _savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, ItemUpdateType.MetadataEdit, false));
}
private IEnumerable<IMetadataProvider<T>> GetMetadataProvidersInternal<T>(BaseItem item, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions, bool includeDisabled, bool forceEnableInternetMetadata)
private IEnumerable<IMetadataProvider<T>> GetMetadataProvidersInternal<T>(BaseItem item, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions, bool includeDisabled, bool forceEnableInternetMetadata, string libraryPath)
where T : BaseItem
{
var localMetadataReaderOrder = libraryOptions.LocalMetadataReaderOrder ?? globalMetadataOptions.LocalMetadataReaderOrder;
var typeOptions = libraryOptions.GetTypeOptions(item.GetType().Name);
var orderedProviders = GetOrCreateOrderedProviders<T>(item.GetType().Name, libraryOptions, globalMetadataOptions, includeDisabled, forceEnableInternetMetadata, libraryPath);
return orderedProviders.Where(i => CanRefreshMetadata(i, item, typeOptions, includeDisabled, forceEnableInternetMetadata));
}
private IMetadataProvider<T>[] GetOrCreateOrderedProviders<T>(
string itemTypeName,
LibraryOptions libraryOptions,
MetadataOptions globalMetadataOptions,
bool includeDisabled,
bool forceEnableInternetMetadata,
string libraryPath)
where T : BaseItem
{
var cacheKey = new MetadataProviderCacheKey(libraryPath, itemTypeName, includeDisabled, forceEnableInternetMetadata);
if (_metadataProviderCache.TryGetValue(cacheKey, out var cachedProviders))
{
return cachedProviders.OfType<IMetadataProvider<T>>().ToArray();
}
var localMetadataReaderOrder = libraryOptions.LocalMetadataReaderOrder ?? globalMetadataOptions.LocalMetadataReaderOrder;
var typeOptions = libraryOptions.GetTypeOptions(itemTypeName);
var metadataFetcherOrder = typeOptions?.MetadataFetcherOrder ?? globalMetadataOptions.MetadataFetcherOrder;
return _metadataProviders.OfType<IMetadataProvider<T>>()
.Where(i => CanRefreshMetadata(i, item, typeOptions, includeDisabled, forceEnableInternetMetadata))
var orderedProviders = _metadataProviders.OfType<IMetadataProvider<T>>()
.Where(i => CanRefreshMetadataForCache(i, typeOptions, includeDisabled, forceEnableInternetMetadata))
.OrderBy(i =>
// local and remote providers will be interleaved in the final order
// only relative order within a type matters: consumers of the list filter to one or the other
@@ -456,7 +517,36 @@ namespace MediaBrowser.Providers.Manager
// Default to end
_ => int.MaxValue
})
.ThenBy(GetDefaultOrder);
.ThenBy(GetDefaultOrder)
.ToArray();
_metadataProviderCache.TryAdd(cacheKey, orderedProviders.Cast<IMetadataProvider>().ToArray());
return orderedProviders;
}
private static bool CanRefreshMetadataForCache(
IMetadataProvider provider,
TypeOptions? libraryTypeOptions,
bool includeDisabled,
bool forceEnableInternetMetadata)
{
if (includeDisabled)
{
return true;
}
if (forceEnableInternetMetadata || provider is not IRemoteMetadataProvider)
{
return true;
}
if (libraryTypeOptions?.MetadataFetchers is { Length: > 0 } metadataFetchers)
{
return metadataFetchers.Contains(provider.Name, StringComparer.OrdinalIgnoreCase);
}
return true;
}
private bool CanRefreshMetadata(
@@ -614,7 +704,8 @@ namespace MediaBrowser.Providers.Manager
private void AddMetadataPlugins<T>(List<MetadataPlugin> list, T item, LibraryOptions libraryOptions, MetadataOptions options)
where T : BaseItem
{
var providers = GetMetadataProvidersInternal<T>(item, libraryOptions, options, true, true).ToList();
var libraryPath = GetLibraryPathForItem(item);
var providers = GetMetadataProvidersInternal<T>(item, libraryOptions, options, true, true, libraryPath).ToList();
// Locals
list.AddRange(providers.Where(i => i is ILocalMetadataProvider).Select(i => new MetadataPlugin
@@ -831,8 +922,8 @@ namespace MediaBrowser.Providers.Manager
}
var options = GetMetadataOptions(referenceItem);
var providers = GetMetadataProvidersInternal<TItemType>(referenceItem, libraryOptions, options, searchInfo.IncludeDisabledProviders, false)
var libraryPath = GetLibraryPathForItem(referenceItem);
var providers = GetMetadataProvidersInternal<TItemType>(referenceItem, libraryOptions, options, searchInfo.IncludeDisabledProviders, false, libraryPath)
.OfType<IRemoteSearchProvider<TLookupType>>();
if (!string.IsNullOrEmpty(searchInfo.SearchProviderName))
@@ -1164,6 +1255,8 @@ namespace MediaBrowser.Providers.Manager
if (disposing)
{
CollectionFolder.LibraryOptionsUpdated -= OnLibraryOptionsUpdated;
if (!_disposeCancellationTokenSource.IsCancellationRequested)
{
_disposeCancellationTokenSource.Cancel();
@@ -1175,5 +1268,38 @@ namespace MediaBrowser.Providers.Manager
_disposed = true;
}
private void OnLibraryOptionsUpdated(object? sender, LibraryOptionsUpdatedEventArgs e)
{
var keysToRemove = _metadataProviderCache.Keys
.Where(k => string.Equals(k.LibraryPath, e.LibraryPath, StringComparison.Ordinal))
.ToList();
foreach (var key in keysToRemove)
{
_metadataProviderCache.TryRemove(key, out _);
}
_logger.LogDebug("Invalidated metadata provider cache for library: {LibraryPath}", e.LibraryPath);
}
internal void ClearMetadataProviderCache()
{
_metadataProviderCache.Clear();
_logger.LogDebug("Cleared entire metadata provider cache");
}
/// <summary>
/// Cache key for metadata provider lookups.
/// </summary>
/// <param name="LibraryPath">The library path for the collection folder.</param>
/// <param name="ItemTypeName">The item type name.</param>
/// <param name="IncludeDisabled">Whether to include disabled providers.</param>
/// <param name="ForceEnableInternetMetadata">Whether internet metadata is force-enabled.</param>
private readonly record struct MetadataProviderCacheKey(
string LibraryPath,
string ItemTypeName,
bool IncludeDisabled,
bool ForceEnableInternetMetadata);
}
}

View File

@@ -175,11 +175,11 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
private LinkedChild GetLinkedChild(string itemPath, string playlistPath, List<string> libraryRoots)
{
if (TryGetPlaylistItemPath(itemPath, playlistPath, libraryRoots, out var parsedPath))
if (TryResolvePlaylistItem(itemPath, playlistPath, libraryRoots, out var item))
{
return new LinkedChild
{
Path = parsedPath,
ItemId = item.Id,
Type = LinkedChildType.Manual
};
}
@@ -187,9 +187,9 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
return null;
}
private bool TryGetPlaylistItemPath(string itemPath, string playlistPath, List<string> libraryPaths, out string path)
private bool TryResolvePlaylistItem(string itemPath, string playlistPath, List<string> libraryPaths, out BaseItem item)
{
path = null;
item = null;
string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath);
if (!File.Exists(pathToCheck))
{
@@ -200,8 +200,8 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
{
if (pathToCheck.StartsWith(libraryPath, StringComparison.OrdinalIgnoreCase))
{
path = pathToCheck;
return true;
item = _libraryManager.FindByPath(pathToCheck, null);
return item is not null;
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Controller.Configuration;
@@ -66,13 +67,24 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo>
{
targetItem.PlaylistMediaType = sourceItem.PlaylistMediaType;
if (replaceData || targetItem.LinkedChildren.Length == 0)
// Only merge LinkedChildren from metadata for external playlists (not managed by Jellyfin).
// For internal playlists, the database LinkedChildren table is the source of truth.
var targetPath = targetItem.Path;
if (!string.IsNullOrEmpty(targetPath)
&& !FileSystem.ContainsSubPath(ServerConfigurationManager.ApplicationPaths.DataPath, targetPath))
{
targetItem.LinkedChildren = sourceItem.LinkedChildren;
}
else
{
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray();
if (replaceData || targetItem.LinkedChildren.Length == 0)
{
targetItem.LinkedChildren = sourceItem.LinkedChildren;
}
else
{
#pragma warning disable CS0618 // Type or member is obsolete - fallback for legacy path-based dedup
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren)
.DistinctBy(i => i.ItemId.HasValue && !i.ItemId.Value.Equals(Guid.Empty) ? i.ItemId.Value.ToString() : i.Path ?? string.Empty)
.ToArray();
#pragma warning restore CS0618
}
}
if (replaceData || targetItem.Shares.Count == 0)

View File

@@ -79,6 +79,7 @@ public class TrickplayImagesTask : IScheduledTask
IsVirtualItem = false,
IsFolder = false,
Recursive = true,
IncludeOwnedItems = true,
Limit = QueryPageLimit
};

View File

@@ -70,7 +70,8 @@ public class TrickplayMoveImagesTask : IScheduledTask
SourceTypes = [SourceType.Library],
IsVirtualItem = false,
IsFolder = false,
Recursive = true
Recursive = true,
IncludeOwnedItems = true
});
var trickplayQuery = new InternalItemsQuery
@@ -78,7 +79,8 @@ public class TrickplayMoveImagesTask : IScheduledTask
MediaTypes = [MediaType.Video],
SourceTypes = [SourceType.Library],
IsVirtualItem = false,
IsFolder = false
IsFolder = false,
IncludeOwnedItems = true
};
do

View File

@@ -781,26 +781,30 @@ namespace MediaBrowser.XbmcMetadata.Savers
private void AddCollectionItems(Folder item, XmlWriter writer)
{
var items = item.LinkedChildren
var linkedChildren = item.LinkedChildren
.Where(i => i.Type == LinkedChildType.Manual)
.OrderBy(i => i.Path?.Trim())
.ThenBy(i => i.LibraryItemId?.Trim())
.ToList();
foreach (var link in items)
// Resolve ItemIds to paths and sort
var itemsWithPaths = linkedChildren
.Select(link =>
{
if (link.ItemId.HasValue && !link.ItemId.Value.Equals(Guid.Empty))
{
var linkedItem = LibraryManager.GetItemById(link.ItemId.Value);
return linkedItem?.Path;
}
return null;
})
.Where(path => !string.IsNullOrWhiteSpace(path))
.OrderBy(path => path?.Trim())
.ToList();
foreach (var path in itemsWithPaths)
{
writer.WriteStartElement("collectionitem");
if (!string.IsNullOrWhiteSpace(link.Path))
{
writer.WriteElementString("path", link.Path);
}
if (!string.IsNullOrWhiteSpace(link.LibraryItemId))
{
writer.WriteElementString("ItemId", link.LibraryItemId);
}
writer.WriteElementString("path", path);
writer.WriteEndElement();
}
}

View File

@@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.MatchCriteria;
namespace Jellyfin.Database.Implementations;
/// <summary>
/// Provides methods for querying item hierarchies using iterative traversal.
/// Uses AncestorIds and LinkedChildren tables for parent-child traversal.
/// </summary>
public static class DescendantQueryHelper
{
/// <summary>
/// Gets a queryable of all descendant IDs for a parent item.
/// Traverses AncestorIds and LinkedChildren to find all descendants.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="parentId">Parent item ID.</param>
/// <returns>Queryable of descendant item IDs.</returns>
public static IQueryable<Guid> GetAllDescendantIds(JellyfinDbContext context, Guid parentId)
{
ArgumentNullException.ThrowIfNull(context);
var descendants = TraverseHierarchyDown(context, [parentId]);
descendants.Remove(parentId);
return descendants.AsQueryable();
}
/// <summary>
/// Gets a queryable of all owned descendant IDs for a parent item.
/// Traverses only AncestorIds (hierarchical ownership), NOT LinkedChildren (associations).
/// Use this for deletion to avoid destroying items that are merely linked (e.g. movies in a BoxSet).
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="parentId">Parent item ID.</param>
/// <returns>Queryable of owned descendant item IDs.</returns>
public static IQueryable<Guid> GetOwnedDescendantIds(JellyfinDbContext context, Guid parentId)
{
ArgumentNullException.ThrowIfNull(context);
var descendants = TraverseHierarchyDownOwned(context, [parentId]);
descendants.Remove(parentId);
return descendants.AsQueryable();
}
/// <summary>
/// Gets all owned descendant IDs for multiple parent items in a single traversal.
/// More efficient than calling <see cref="GetOwnedDescendantIds"/> per parent because
/// it performs one traversal for all seeds instead of N separate traversals.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="parentIds">Parent item IDs.</param>
/// <returns>Set of all owned descendant item IDs (excluding the parent IDs themselves).</returns>
public static HashSet<Guid> GetOwnedDescendantIdsBatch(JellyfinDbContext context, IReadOnlyList<Guid> parentIds)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(parentIds);
if (parentIds.Count == 0)
{
return [];
}
var seedSet = new HashSet<Guid>(parentIds);
var descendants = TraverseHierarchyDownOwned(context, seedSet);
// Remove the seed IDs — callers want only descendants
descendants.ExceptWith(seedSet);
return descendants;
}
/// <summary>
/// Gets a queryable of all folder IDs that have any descendant matching the specified criteria.
/// Can be used in LINQ .Contains() expressions.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="criteria">The matching criteria to apply.</param>
/// <returns>Queryable of folder IDs.</returns>
public static IQueryable<Guid> GetFolderIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(criteria);
var matchingItemIds = criteria switch
{
HasSubtitles => context.MediaStreamInfos
.Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle)
.Select(ms => ms.ItemId)
.Distinct()
.ToHashSet(),
HasChapterImages => context.Chapters
.Where(c => c.ImagePath != null)
.Select(c => c.ItemId)
.Distinct()
.ToHashSet(),
HasMediaStreamType m => GetMatchingMediaStreamItemIds(context, m),
_ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}")
};
var ancestors = TraverseHierarchyUp(context, matchingItemIds);
return ancestors.AsQueryable();
}
private static HashSet<Guid> GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria)
{
var query = context.MediaStreamInfos
.Where(ms => ms.StreamType == criteria.StreamType && ms.Language == criteria.Language);
if (criteria.IsExternal.HasValue)
{
var isExternal = criteria.IsExternal.Value;
query = query.Where(ms => ms.IsExternal == isExternal);
}
return query.Select(ms => ms.ItemId).Distinct().ToHashSet();
}
/// <summary>
/// Traverses DOWN the hierarchy from parent folders to find all descendants.
/// </summary>
private static HashSet<Guid> TraverseHierarchyDown(JellyfinDbContext context, ICollection<Guid> startIds)
{
var visited = new HashSet<Guid>(startIds);
var folderStack = new HashSet<Guid>(startIds);
while (folderStack.Count != 0)
{
var currentFolders = folderStack.ToArray();
folderStack.Clear();
var directChildren = context.AncestorIds
.WhereOneOrMany(currentFolders, e => e.ParentItemId)
.Select(e => e.ItemId)
.ToArray();
var linkedChildren = context.LinkedChildren
.WhereOneOrMany(currentFolders, e => e.ParentId)
.Select(e => e.ChildId)
.ToArray();
var allChildren = directChildren.Concat(linkedChildren).Distinct().ToArray();
if (allChildren.Length == 0)
{
break;
}
var childFolders = context.BaseItems
.WhereOneOrMany(allChildren, e => e.Id)
.Where(e => e.IsFolder)
.Select(e => e.Id)
.ToHashSet();
foreach (var childId in allChildren)
{
if (visited.Add(childId) && childFolders.Contains(childId))
{
folderStack.Add(childId);
}
}
}
return visited;
}
/// <summary>
/// Traverses DOWN the hierarchy using only AncestorIds (ownership), not LinkedChildren.
/// </summary>
private static HashSet<Guid> TraverseHierarchyDownOwned(JellyfinDbContext context, ICollection<Guid> startIds)
{
var visited = new HashSet<Guid>(startIds);
var folderStack = new HashSet<Guid>(startIds);
while (folderStack.Count != 0)
{
var currentFolders = folderStack.ToArray();
folderStack.Clear();
var directChildren = context.AncestorIds
.WhereOneOrMany(currentFolders, e => e.ParentItemId)
.Select(e => e.ItemId)
.ToArray();
if (directChildren.Length == 0)
{
break;
}
var childFolders = context.BaseItems
.WhereOneOrMany(directChildren, e => e.Id)
.Where(e => e.IsFolder)
.Select(e => e.Id)
.ToHashSet();
foreach (var childId in directChildren)
{
if (visited.Add(childId) && childFolders.Contains(childId))
{
folderStack.Add(childId);
}
}
}
return visited;
}
/// <summary>
/// Traverses UP the hierarchy from items to find all ancestor folders.
/// </summary>
private static HashSet<Guid> TraverseHierarchyUp(JellyfinDbContext context, ICollection<Guid> startIds)
{
var ancestors = new HashSet<Guid>();
var itemStack = new HashSet<Guid>(startIds);
while (itemStack.Count != 0)
{
var currentItems = itemStack.ToArray();
itemStack.Clear();
var ancestorParents = context.AncestorIds
.WhereOneOrMany(currentItems, e => e.ItemId)
.Select(e => e.ParentItemId)
.ToArray();
var linkedParents = context.LinkedChildren
.WhereOneOrMany(currentItems, e => e.ChildId)
.Select(e => e.ParentId)
.ToArray();
foreach (var parentId in ancestorParents.Concat(linkedParents))
{
if (ancestors.Add(parentId))
{
itemStack.Add(parentId);
}
}
}
return ancestors;
}
}

View File

@@ -96,7 +96,7 @@ public class BaseItemEntity
public string? OriginalTitle { get; set; }
public string? PrimaryVersionId { get; set; }
public Guid? PrimaryVersionId { get; set; }
public DateTime? DateLastMediaAdded { get; set; }
@@ -118,8 +118,6 @@ public class BaseItemEntity
public string? ProductionLocations { get; set; }
public string? ExtraIds { get; set; }
public int? TotalBitrate { get; set; }
public BaseItemExtraType? ExtraType { get; set; }
@@ -134,7 +132,17 @@ public class BaseItemEntity
public string? ShowId { get; set; }
public string? OwnerId { get; set; }
public Guid? OwnerId { get; set; }
/// <summary>
/// Gets or sets the owner item (for extras like trailers, theme songs, etc.).
/// </summary>
public BaseItemEntity? Owner { get; set; }
/// <summary>
/// Gets or sets the extras owned by this item (trailers, theme songs, behind the scenes, etc.).
/// </summary>
public ICollection<BaseItemEntity>? Extras { get; set; }
public int? Width { get; set; }
@@ -178,6 +186,16 @@ public class BaseItemEntity
public ICollection<BaseItemImageInfo>? Images { get; set; }
/// <summary>
/// Gets or sets the linked children (for BoxSets, Playlists, etc.).
/// </summary>
public ICollection<LinkedChildEntity>? LinkedChildEntities { get; set; }
/// <summary>
/// Gets or sets the items this entity is linked to as a child.
/// </summary>
public ICollection<LinkedChildEntity>? LinkedChildOfEntities { get; set; }
// those are references to __LOCAL__ ids not DB ids ... TODO: Bring the whole folder structure into the DB
// public ICollection<BaseItemEntity>? SeriesEpisodes { get; set; }
// public BaseItemEntity? Series { get; set; }

View File

@@ -0,0 +1,39 @@
using System;
namespace Jellyfin.Database.Implementations.Entities;
/// <summary>
/// Represents a linked child relationship between items (e.g., BoxSet to Movies, Playlist to tracks).
/// </summary>
public class LinkedChildEntity
{
/// <summary>
/// Gets or sets the parent item ID (BoxSet, Playlist, etc.).
/// </summary>
public required Guid ParentId { get; set; }
/// <summary>
/// Gets or sets the child item ID.
/// </summary>
public required Guid ChildId { get; set; }
/// <summary>
/// Gets or sets the type of linked child (Manual or Shortcut).
/// </summary>
public required LinkedChildType ChildType { get; set; }
/// <summary>
/// Gets or sets the sort order.
/// </summary>
public int? SortOrder { get; set; }
/// <summary>
/// Gets or sets the parent item navigation property.
/// </summary>
public BaseItemEntity? Parent { get; set; }
/// <summary>
/// Gets or sets the child item navigation property.
/// </summary>
public BaseItemEntity? Child { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace Jellyfin.Database.Implementations.Entities;
/// <summary>
/// The linked child type.
/// </summary>
public enum LinkedChildType
{
/// <summary>
/// Manually linked child.
/// </summary>
Manual = 0,
/// <summary>
/// Shortcut linked child.
/// </summary>
Shortcut = 1,
/// <summary>
/// Local alternate version (same item, different file path).
/// </summary>
LocalAlternateVersion = 2,
/// <summary>
/// Linked alternate version (different item ID).
/// </summary>
LinkedAlternateVersion = 3
}

View File

@@ -143,6 +143,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
/// </summary>
public DbSet<PeopleBaseItemMap> PeopleBaseItemMap => Set<PeopleBaseItemMap>();
/// <summary>
/// Gets the <see cref="DbSet{TEntity}"/> containing linked children relationships.
/// </summary>
public DbSet<LinkedChildEntity> LinkedChildren => Set<LinkedChildEntity>();
/// <summary>
/// Gets the <see cref="DbSet{TEntity}"/> containing the referenced Providers with ids.
/// </summary>

View File

@@ -0,0 +1,6 @@
namespace Jellyfin.Database.Implementations.MatchCriteria;
/// <summary>
/// Base type for folder matching criteria using discriminated union pattern.
/// </summary>
public abstract record FolderMatchCriteria;

View File

@@ -0,0 +1,6 @@
namespace Jellyfin.Database.Implementations.MatchCriteria;
/// <summary>
/// Matches folders containing descendants with chapter images.
/// </summary>
public sealed record HasChapterImages : FolderMatchCriteria;

View File

@@ -0,0 +1,14 @@
using Jellyfin.Database.Implementations.Entities;
namespace Jellyfin.Database.Implementations.MatchCriteria;
/// <summary>
/// Matches folders containing descendants with a specific media stream type and language.
/// </summary>
/// <param name="StreamType">The type of media stream to match (Audio, Subtitle, etc.).</param>
/// <param name="Language">The language to match.</param>
/// <param name="IsExternal">If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles.</param>
public sealed record HasMediaStreamType(
MediaStreamTypeEntity StreamType,
string Language,
bool? IsExternal = null) : FolderMatchCriteria;

View File

@@ -0,0 +1,6 @@
namespace Jellyfin.Database.Implementations.MatchCriteria;
/// <summary>
/// Matches folders containing descendants with subtitles.
/// </summary>
public sealed record HasSubtitles : FolderMatchCriteria;

View File

@@ -28,15 +28,17 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity>
builder.HasMany(e => e.Parents);
builder.HasMany(e => e.Children);
builder.HasMany(e => e.DirectChildren).WithOne(e => e.DirectParent).HasForeignKey(e => e.ParentId).OnDelete(DeleteBehavior.Cascade);
builder.HasMany(e => e.Extras).WithOne(e => e.Owner).HasForeignKey(e => e.OwnerId).OnDelete(DeleteBehavior.NoAction);
builder.HasMany(e => e.LockedFields);
builder.HasMany(e => e.TrailerTypes);
builder.HasMany(e => e.Images);
builder.HasIndex(e => e.Path);
builder.HasIndex(e => e.ParentId);
builder.HasIndex(e => e.OwnerId);
builder.HasIndex(e => e.Name);
builder.HasIndex(e => new { e.ExtraType, e.OwnerId });
builder.HasIndex(e => e.PresentationUniqueKey);
builder.HasIndex(e => new { e.Id, e.Type, e.IsFolder, e.IsVirtualItem });
// covering index
builder.HasIndex(e => new { e.TopParentId, e.Id });
// series
@@ -53,14 +55,29 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity>
// latest items
builder.HasIndex(e => new { e.Type, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey, e.DateCreated });
builder.HasIndex(e => new { e.IsFolder, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey, e.DateCreated });
// latest items - optimized for sorting by DateCreated (no PresentationUniqueKey breaking the sort)
builder.HasIndex(e => new { e.TopParentId, e.Type, e.IsVirtualItem, e.DateCreated });
builder.HasIndex(e => new { e.TopParentId, e.IsFolder, e.IsVirtualItem, e.DateCreated });
builder.HasIndex(e => new { e.TopParentId, e.MediaType, e.IsVirtualItem, e.DateCreated });
// resume
builder.HasIndex(e => new { e.MediaType, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey });
// sorted library queries (e.g., Series sorted by SortName)
builder.HasIndex(e => new { e.Type, e.TopParentId, e.SortName });
// NextUp: per-series episode ordering (index seek + range scan on season/episode)
builder.HasIndex(e => new { e.Type, e.SeriesPresentationUniqueKey, e.ParentIndexNumber, e.IndexNumber });
// ByName queries: WHERE Type = X AND CleanName IN (...)
builder.HasIndex(e => new { e.Type, e.CleanName });
// Latest TV: GROUP BY SeriesName
builder.HasIndex(e => e.SeriesName);
// Latest TV: episode count per season, season count per series
builder.HasIndex(e => e.SeasonId);
builder.HasIndex(e => e.SeriesId);
builder.HasData(new BaseItemEntity()
{
Id = Guid.Parse("00000000-0000-0000-0000-000000000001"),
Type = "PLACEHOLDER",
Name = "This is a placeholder item for UserData that has been detacted from its original item",
Name = "This is a placeholder item for UserData that has been detached from its original item",
});
}
}

View File

@@ -0,0 +1,21 @@
using Jellyfin.Database.Implementations.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Jellyfin.Database.Implementations.ModelConfiguration;
/// <summary>
/// FluentAPI configuration for the BaseItemImageInfo entity.
/// </summary>
public class BaseItemImageInfoConfiguration : IEntityTypeConfiguration<BaseItemImageInfo>
{
/// <inheritdoc/>
public void Configure(EntityTypeBuilder<BaseItemImageInfo> builder)
{
builder.HasKey(e => e.Id);
builder.HasOne(e => e.Item).WithMany(e => e.Images).HasForeignKey(e => e.ItemId);
// Composite index for filtering by item and image type (also covers ItemId-only lookups)
builder.HasIndex(e => new { e.ItemId, e.ImageType });
}
}

View File

@@ -14,6 +14,6 @@ public class BaseItemProviderConfiguration : IEntityTypeConfiguration<BaseItemPr
{
builder.HasKey(e => new { e.ItemId, e.ProviderId });
builder.HasOne(e => e.Item);
builder.HasIndex(e => new { e.ProviderId, e.ProviderValue, e.ItemId });
builder.HasIndex(e => new { e.ProviderId, e.ItemId, e.ProviderValue });
}
}

View File

@@ -20,9 +20,6 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
builder
.HasIndex(entity => new { entity.UserId, entity.DeviceId });
builder
.HasIndex(entity => entity.DeviceId);
}
}
}

View File

@@ -0,0 +1,31 @@
using Jellyfin.Database.Implementations.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Jellyfin.Database.Implementations.ModelConfiguration;
/// <summary>
/// LinkedChildEntity configuration.
/// </summary>
public class LinkedChildConfiguration : IEntityTypeConfiguration<LinkedChildEntity>
{
/// <inheritdoc/>
public void Configure(EntityTypeBuilder<LinkedChildEntity> builder)
{
builder.ToTable("LinkedChildren");
builder.HasKey(e => new { e.ParentId, e.ChildId });
builder.HasIndex(e => new { e.ParentId, e.SortOrder });
builder.HasIndex(e => new { e.ParentId, e.ChildType });
builder.HasIndex(e => new { e.ChildId, e.ChildType });
builder.HasOne(e => e.Parent)
.WithMany(e => e.LinkedChildEntities)
.HasForeignKey(e => e.ParentId)
.OnDelete(DeleteBehavior.NoAction);
builder.HasOne(e => e.Child)
.WithMany(e => e.LinkedChildOfEntities)
.HasForeignKey(e => e.ChildId)
.OnDelete(DeleteBehavior.NoAction);
}
}

View File

@@ -13,9 +13,5 @@ public class MediaStreamInfoConfiguration : IEntityTypeConfiguration<MediaStream
public void Configure(EntityTypeBuilder<MediaStreamInfo> builder)
{
builder.HasKey(e => new { e.ItemId, e.StreamIndex });
builder.HasIndex(e => e.StreamIndex);
builder.HasIndex(e => e.StreamType);
builder.HasIndex(e => new { e.StreamIndex, e.StreamType });
builder.HasIndex(e => new { e.StreamIndex, e.StreamType, e.Language });
}
}

View File

@@ -15,6 +15,7 @@ public class PeopleBaseItemMapConfiguration : IEntityTypeConfiguration<PeopleBas
builder.HasKey(e => new { e.ItemId, e.PeopleId, e.Role });
builder.HasIndex(e => new { e.ItemId, e.SortOrder });
builder.HasIndex(e => new { e.ItemId, e.ListOrder });
builder.HasIndex(e => e.PeopleId);
builder.HasOne(e => e.Item);
builder.HasOne(e => e.People);
}

Some files were not shown because too many files have changed in this diff Show More