mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-03 23:36:38 +01:00
Merge pull request #16062 from Shadowghost/perf-rebased
Query Performance Improvements
This commit is contained in:
@@ -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>>();
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
489
Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
Normal file
489
Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
427
Jellyfin.Server.Implementations/Item/ItemCountService.cs
Normal file
427
Jellyfin.Server.Implementations/Item/ItemCountService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
664
Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
Normal file
664
Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
162
Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
Normal file
162
Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
353
Jellyfin.Server.Implementations/Item/NextUpService.cs
Normal file
353
Jellyfin.Server.Implementations/Item/NextUpService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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!");
|
||||
|
||||
|
||||
112
Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs
Normal file
112
Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
589
Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs
Normal file
589
Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,8 @@ public class MoveTrickplayFiles : IMigrationRoutine
|
||||
MediaTypes = [MediaType.Video],
|
||||
SourceTypes = [SourceType.Library],
|
||||
IsVirtualItem = false,
|
||||
IsFolder = false
|
||||
IsFolder = false,
|
||||
IncludeOwnedItems = true
|
||||
};
|
||||
|
||||
do
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
86
MediaBrowser.Controller/Persistence/IItemCountService.cs
Normal file
86
MediaBrowser.Controller/Persistence/IItemCountService.cs
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
107
MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs
Normal file
107
MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs
Normal 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);
|
||||
}
|
||||
@@ -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<BaseItem>.</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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
33
MediaBrowser.Controller/Persistence/INextUpService.cs
Normal file
33
MediaBrowser.Controller/Persistence/INextUpService.cs
Normal 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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -79,6 +79,7 @@ public class TrickplayImagesTask : IScheduledTask
|
||||
IsVirtualItem = false,
|
||||
IsFolder = false,
|
||||
Recursive = true,
|
||||
IncludeOwnedItems = true,
|
||||
Limit = QueryPageLimit
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Jellyfin.Database.Implementations.MatchCriteria;
|
||||
|
||||
/// <summary>
|
||||
/// Matches folders containing descendants with chapter images.
|
||||
/// </summary>
|
||||
public sealed record HasChapterImages : FolderMatchCriteria;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Jellyfin.Database.Implementations.MatchCriteria;
|
||||
|
||||
/// <summary>
|
||||
/// Matches folders containing descendants with subtitles.
|
||||
/// </summary>
|
||||
public sealed record HasSubtitles : FolderMatchCriteria;
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
|
||||
|
||||
builder
|
||||
.HasIndex(entity => new { entity.UserId, entity.DeviceId });
|
||||
|
||||
builder
|
||||
.HasIndex(entity => entity.DeviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user