mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-03 23:36:38 +01:00
Complete LinkedChildren integration and batch DTO optimizations
This commit integrates remaining performance changes: - Add batch user data fetching in DtoService to reduce N+1 queries - Add GetNextUpEpisodesBatch in TVSeriesManager for efficient batch retrieval - Update Video/Movie/BoxSet to use LibraryManager for alternate versions - Transition LinkedChild to use ItemId instead of Path (obsolete Path/LibraryItemId) - Update providers and controllers for LinkedChildren-based references - Add NextUpEpisodeBatchResult for batched episode queries - Integrate IDescendantQueryProvider in SqliteDatabaseProvider
This commit is contained in:
@@ -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))
|
||||
{
|
||||
|
||||
@@ -153,17 +153,42 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (item is LiveTvChannel tvChannel)
|
||||
{
|
||||
@@ -197,7 +222,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 +240,7 @@ 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)
|
||||
{
|
||||
var dto = new BaseItemDto
|
||||
{
|
||||
@@ -252,7 +277,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
AttachUserSpecificInfo(dto, item, user, options);
|
||||
AttachUserSpecificInfo(dto, item, user, options, userData, childCountBatch);
|
||||
}
|
||||
|
||||
if (item is IHasMediaSources
|
||||
@@ -274,7 +299,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))
|
||||
@@ -458,7 +485,7 @@ 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)
|
||||
{
|
||||
if (item.IsFolder)
|
||||
{
|
||||
@@ -466,7 +493,17 @@ 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);
|
||||
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to individual fetch
|
||||
dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dto.ChildCount.HasValue && item.SourceType == SourceType.Library)
|
||||
@@ -485,7 +522,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.ContainsField(ItemFields.ChildCount))
|
||||
{
|
||||
dto.ChildCount ??= GetChildCount(folder, user);
|
||||
dto.ChildCount ??= GetChildCount(folder, user, childCountBatch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,7 +540,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 +560,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 +587,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -406,6 +406,37 @@ namespace Emby.Server.Implementations.Library
|
||||
item.Id);
|
||||
}
|
||||
|
||||
// If deleting a primary version video, clear PrimaryVersionId from alternate versions
|
||||
if (item is Video video && string.IsNullOrEmpty(video.PrimaryVersionId))
|
||||
{
|
||||
var alternateVersions = GetLocalAlternateVersionIds(video)
|
||||
.Concat(GetLinkedAlternateVersions(video).Select(v => v.Id))
|
||||
.Distinct()
|
||||
.Select(id => GetItemById(id))
|
||||
.OfType<Video>()
|
||||
.ToList();
|
||||
|
||||
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.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Update remaining alternates to point to new primary
|
||||
foreach (var alternate in alternateVersions.Skip(1))
|
||||
{
|
||||
alternate.SetPrimaryVersionId(newPrimary.Id.ToString("N", CultureInfo.InvariantCulture));
|
||||
alternate.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var children = item.IsFolder
|
||||
? ((Folder)item).GetRecursiveChildren(false)
|
||||
: [];
|
||||
@@ -576,6 +607,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))
|
||||
{
|
||||
@@ -1421,14 +1455,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)
|
||||
@@ -1474,6 +1501,11 @@ namespace Emby.Server.Implementations.Library
|
||||
return _itemRepository.GetItemCounts(query);
|
||||
}
|
||||
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
|
||||
{
|
||||
return _itemRepository.GetChildCountBatch(parentIds, userId);
|
||||
}
|
||||
|
||||
public IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery query, List<BaseItem> parents)
|
||||
{
|
||||
SetTopParentIdsOrAncestors(query, parents);
|
||||
@@ -1519,6 +1551,16 @@ namespace Emby.Server.Implementations.Library
|
||||
return _itemRepository.GetNextUpSeriesKeys(query, dateCutoff);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult> GetNextUpEpisodesBatch(
|
||||
InternalItemsQuery query,
|
||||
IReadOnlyList<string> seriesKeys,
|
||||
bool includeSpecials,
|
||||
bool includeWatchedForRewatching)
|
||||
{
|
||||
return _itemRepository.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeWatchedForRewatching);
|
||||
}
|
||||
|
||||
public QueryResult<BaseItem> QueryItems(InternalItemsQuery query)
|
||||
{
|
||||
if (query.User is not null)
|
||||
@@ -1700,6 +1742,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 +1772,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 +1945,38 @@ namespace Emby.Server.Implementations.Library
|
||||
return video;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Guid> GetLocalAlternateVersionIds(Video video)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(video);
|
||||
|
||||
var linkedIds = _itemRepository.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 = _itemRepository.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 IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder)
|
||||
{
|
||||
@@ -2896,10 +2984,17 @@ namespace Emby.Server.Implementations.Library
|
||||
extra.ExtraType = extraType;
|
||||
}
|
||||
|
||||
extra.ParentId = Guid.Empty;
|
||||
extra.OwnerId = owner.Id;
|
||||
extra.IsInMixedFolder = isInMixedFolder;
|
||||
return extra;
|
||||
// Only set OwnerId if this is actually an extra (not Unknown or null)
|
||||
if (extra.ExtraType is not null)
|
||||
{
|
||||
extra.ParentId = Guid.Empty;
|
||||
extra.OwnerId = owner.Id;
|
||||
extra.IsInMixedFolder = isInMixedFolder;
|
||||
|
||||
return extra;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Collections;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
@@ -11,7 +11,6 @@ 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;
|
||||
|
||||
@@ -27,6 +26,7 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask
|
||||
private readonly IPlaylistManager _playlistManager;
|
||||
private readonly ILogger<CleanupCollectionAndPlaylistPathsTask> _logger;
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CleanupCollectionAndPlaylistPathsTask"/> class.
|
||||
@@ -36,18 +36,21 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask
|
||||
/// <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>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
public CleanupCollectionAndPlaylistPathsTask(
|
||||
ILocalizationManager localization,
|
||||
ICollectionManager collectionManager,
|
||||
IPlaylistManager playlistManager,
|
||||
ILogger<CleanupCollectionAndPlaylistPathsTask> logger,
|
||||
IProviderManager providerManager)
|
||||
IProviderManager providerManager,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_localization = localization;
|
||||
_collectionManager = collectionManager;
|
||||
_playlistManager = playlistManager;
|
||||
_logger = logger;
|
||||
_providerManager = providerManager;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -111,12 +114,15 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask
|
||||
List<LinkedChild>? itemsToRemove = null;
|
||||
foreach (var linkedChild in folder.LinkedChildren)
|
||||
{
|
||||
var path = linkedChild.Path;
|
||||
if (!File.Exists(path) && !Directory.Exists(path))
|
||||
if (linkedChild.ItemId.HasValue
|
||||
&& !linkedChild.ItemId.Value.IsEmpty()
|
||||
&& _libraryManager.GetItemById(linkedChild.ItemId.Value) is not null)
|
||||
{
|
||||
_logger.LogInformation("Item in {FolderName} cannot be found at {ItemPath}", folder.Name, path);
|
||||
(itemsToRemove ??= new List<LinkedChild>()).Add(linkedChild);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Item in {FolderName} with ItemId {ItemId} no longer exists in library", folder.Name, linkedChild.ItemId);
|
||||
(itemsToRemove ??= []).Add(linkedChild);
|
||||
}
|
||||
|
||||
if (itemsToRemove is not null)
|
||||
|
||||
@@ -1820,7 +1820,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,133 @@ 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);
|
||||
}
|
||||
|
||||
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 +237,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;
|
||||
|
||||
@@ -456,19 +456,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>
|
||||
@@ -937,24 +936,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
|
||||
|
||||
@@ -157,7 +157,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>();
|
||||
@@ -222,18 +222,18 @@ public class VideosController : BaseJellyfinApiController
|
||||
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase)))
|
||||
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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,8 +36,9 @@ 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.
|
||||
|
||||
@@ -1806,10 +1806,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);
|
||||
@@ -1824,13 +1837,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;
|
||||
@@ -1838,6 +1852,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
return null;
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
/// <summary>
|
||||
/// Adds a studio to the item.
|
||||
|
||||
@@ -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>
|
||||
@@ -168,6 +173,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]
|
||||
@@ -455,6 +459,14 @@ namespace MediaBrowser.Controller.Entities
|
||||
// If it's an AggregateFolder, don't remove
|
||||
if (shouldRemove && itemsRemoved.Count > 0)
|
||||
{
|
||||
// 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);
|
||||
|
||||
foreach (var item in itemsRemoved)
|
||||
{
|
||||
if (!item.CanDelete())
|
||||
@@ -463,6 +475,24 @@ namespace MediaBrowser.Controller.Entities
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip items that are alternate versions of another video
|
||||
if (item is Video video)
|
||||
{
|
||||
// Check via PrimaryVersionId
|
||||
if (!string.IsNullOrEmpty(video.PrimaryVersionId))
|
||||
{
|
||||
Logger.LogDebug("Item is an alternate version (via PrimaryVersionId), skipping deletion: {Path}", item.Path ?? item.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.IsFileProtocol)
|
||||
{
|
||||
Logger.LogDebug("Removed item: {Path}", item.Path);
|
||||
@@ -806,104 +836,12 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1012,29 +950,6 @@ namespace MediaBrowser.Controller.Entities
|
||||
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);
|
||||
}
|
||||
|
||||
var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
|
||||
var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager);
|
||||
|
||||
@@ -1664,11 +1579,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);
|
||||
@@ -1786,38 +1703,42 @@ namespace MediaBrowser.Controller.Entities
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount))
|
||||
if (SupportsPlayedStatus || (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)))
|
||||
{
|
||||
itemDto.RecursiveItemCount = GetRecursiveChildCount(user);
|
||||
}
|
||||
var query = new InternalItemsQuery(user);
|
||||
LibraryManager.ConfigureUserAccess(query, user);
|
||||
|
||||
if (SupportsPlayedStatus)
|
||||
{
|
||||
var unplayedQueryResult = GetItems(new InternalItemsQuery(user)
|
||||
int playedCount;
|
||||
int totalCount;
|
||||
|
||||
if (LinkedChildren.Length > 0)
|
||||
{
|
||||
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;
|
||||
(playedCount, totalCount) = ItemRepository.GetPlayedAndTotalCountFromLinkedChildren(query, Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
dto.Played = (dto.UnplayedItemCount ?? 0) == 0;
|
||||
(playedCount, totalCount) = ItemRepository.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
ExcludeItemIds = Array.Empty<Guid>();
|
||||
ExcludeItemTypes = Array.Empty<BaseItemKind>();
|
||||
ExcludeTags = Array.Empty<string>();
|
||||
ExtraTypes = Array.Empty<ExtraType>();
|
||||
GenreIds = Array.Empty<Guid>();
|
||||
Genres = Array.Empty<string>();
|
||||
GroupByPresentationUniqueKey = true;
|
||||
@@ -44,6 +45,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
MediaTypes = Array.Empty<MediaType>();
|
||||
OfficialRatings = Array.Empty<string>();
|
||||
OrderBy = Array.Empty<(ItemSortBy, SortOrder)>();
|
||||
OwnerIds = Array.Empty<Guid>();
|
||||
PersonIds = Array.Empty<Guid>();
|
||||
PersonTypes = Array.Empty<string>();
|
||||
PresetViews = Array.Empty<CollectionType?>();
|
||||
@@ -369,6 +371,8 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
public bool SkipDeserialization { get; set; }
|
||||
|
||||
public bool IncludeExtras { get; set; }
|
||||
|
||||
public void SetUser(User user)
|
||||
{
|
||||
var maxRating = user.MaxParentalRatingScore;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
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.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
|
||||
namespace MediaBrowser.Controller.Entities.Movies
|
||||
{
|
||||
@@ -28,9 +28,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.
|
||||
@@ -85,6 +83,34 @@ namespace MediaBrowser.Controller.Entities.Movies
|
||||
return info;
|
||||
}
|
||||
|
||||
protected override async Task RefreshMetadataForVersions(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var newOptions = new MetadataRefreshOptions(options)
|
||||
{
|
||||
SearchResult = null
|
||||
};
|
||||
|
||||
var id = LibraryManager.GetNewItemId(path, typeof(Movie));
|
||||
if (LibraryManager.GetItemById(id) is not Movie movie)
|
||||
{
|
||||
movie = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Movie;
|
||||
|
||||
newOptions.ForceSave = true;
|
||||
}
|
||||
|
||||
if (movie is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (movie.OwnerId.Equals(Guid.Empty))
|
||||
{
|
||||
movie.OwnerId = Id;
|
||||
}
|
||||
|
||||
await RefreshMetadataForOwnedItem(movie, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool BeforeMetadataRefresh(bool replaceAllMetadata)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -418,7 +416,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
items = items.Where(i => Filter(i, query.User, query, _userDataManager, _libraryManager));
|
||||
|
||||
return PostFilterAndSort(items, null, query, _libraryManager);
|
||||
return SortAndPage(items, null, query, _libraryManager);
|
||||
}
|
||||
|
||||
public static bool FilterItem(BaseItem item, InternalItemsQuery query)
|
||||
@@ -426,21 +424,6 @@ namespace MediaBrowser.Controller.Entities
|
||||
return Filter(item, query.User, query, BaseItem.UserDataManager, BaseItem.LibraryManager);
|
||||
}
|
||||
|
||||
public static QueryResult<BaseItem> PostFilterAndSort(
|
||||
IEnumerable<BaseItem> items,
|
||||
int? totalRecordLimit,
|
||||
InternalItemsQuery query,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
// This must be the last filter
|
||||
if (!query.AdjacentTo.IsNullOrEmpty())
|
||||
{
|
||||
items = FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
|
||||
}
|
||||
|
||||
return SortAndPage(items, totalRecordLimit, query, libraryManager);
|
||||
}
|
||||
|
||||
public static QueryResult<BaseItem> SortAndPage(
|
||||
IEnumerable<BaseItem> items,
|
||||
int? totalRecordLimit,
|
||||
@@ -556,38 +539,6 @@ namespace MediaBrowser.Controller.Entities
|
||||
}
|
||||
}
|
||||
|
||||
if (query.IsPlayed.HasValue)
|
||||
{
|
||||
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 +596,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 +623,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 +747,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 +800,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
return GetMediaFolders(user, viewTypes);
|
||||
}
|
||||
|
||||
return new BaseItem[] { parent };
|
||||
return [parent];
|
||||
}
|
||||
|
||||
private UserView GetUserViewWithName(CollectionType? type, string sortName, BaseItem parent)
|
||||
|
||||
@@ -160,7 +160,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; }
|
||||
|
||||
@@ -260,7 +260,10 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
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 +271,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()
|
||||
@@ -364,11 +370,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 +383,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>
|
||||
@@ -454,7 +446,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
RefreshLinkedAlternateVersions();
|
||||
|
||||
var tasks = LocalAlternateVersions
|
||||
.Select(i => RefreshMetadataForOwnedVideo(options, false, i, cancellationToken));
|
||||
.Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken));
|
||||
|
||||
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
}
|
||||
@@ -463,6 +455,39 @@ namespace MediaBrowser.Controller.Entities
|
||||
return hasChanges;
|
||||
}
|
||||
|
||||
protected virtual async Task RefreshMetadataForVersions(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private new async Task RefreshMetadataForOwnedVideo(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var newOptions = new MetadataRefreshOptions(options)
|
||||
{
|
||||
SearchResult = null
|
||||
};
|
||||
|
||||
var id = LibraryManager.GetNewItemId(path, typeof(Video));
|
||||
if (LibraryManager.GetItemById(id) is not Video video)
|
||||
{
|
||||
video = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Video;
|
||||
|
||||
newOptions.ForceSave = true;
|
||||
}
|
||||
|
||||
if (video is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.OwnerId.IsEmpty())
|
||||
{
|
||||
video.OwnerId = Id;
|
||||
}
|
||||
|
||||
await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void RefreshLinkedAlternateVersions()
|
||||
{
|
||||
foreach (var child in LinkedAlternateVersions)
|
||||
@@ -480,7 +505,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,7 +562,7 @@ 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))
|
||||
{
|
||||
@@ -545,14 +570,14 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -213,6 +213,22 @@ 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>
|
||||
/// Adds the parts.
|
||||
/// </summary>
|
||||
@@ -600,6 +616,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 +679,23 @@ namespace MediaBrowser.Controller.Library
|
||||
|
||||
ItemCounts GetItemCounts(InternalItemsQuery query);
|
||||
|
||||
/// <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>
|
||||
/// 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);
|
||||
|
||||
@@ -87,6 +87,21 @@ public interface IItemRepository
|
||||
/// <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.
|
||||
/// Returns the last watched episode, next unwatched episode, specials, and next played episode for each series.
|
||||
/// </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 (ParentIndexNumber = 0) in the results.</param>
|
||||
/// <param name="includeWatchedForRewatching">Whether to include watched episodes for rewatching mode.</param>
|
||||
/// <returns>A dictionary mapping series key to batch result containing episodes needed for NextUp calculation.</returns>
|
||||
IReadOnlyDictionary<string, NextUpEpisodeBatchResult> GetNextUpEpisodesBatch(
|
||||
InternalItemsQuery filter,
|
||||
IReadOnlyList<string> seriesKeys,
|
||||
bool includeSpecials,
|
||||
bool includeWatchedForRewatching);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the inherited values.
|
||||
/// </summary>
|
||||
@@ -132,10 +147,67 @@ public interface IItemRepository
|
||||
/// <returns>A value indicating whever all children has been played.</returns>
|
||||
bool GetIsPlayed(User user, Guid id, bool recursive);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of played items that are descendants of the specified ancestor.
|
||||
/// Uses the AncestorIds table for efficient recursive lookup.
|
||||
/// Applies user access filtering (library access, parental controls, tags).
|
||||
/// </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.
|
||||
/// Uses the AncestorIds table for efficient recursive lookup.
|
||||
/// Applies user access filtering (library access, parental controls, tags).
|
||||
/// </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 items that are descendants of the specified ancestor.
|
||||
/// Uses the AncestorIds table for efficient recursive lookup.
|
||||
/// Applies user access filtering (library access, parental controls, tags).
|
||||
/// </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 of items that are linked children of the specified parent.
|
||||
/// Uses the LinkedChildren table for BoxSets, Playlists, etc.
|
||||
/// Applies user access filtering (library access, parental controls, tags).
|
||||
/// </summary>
|
||||
/// <param name="filter">The query filter containing user access settings.</param>
|
||||
/// <param name="parentId">The parent item id (BoxSet, Playlist, etc.).</param>
|
||||
/// <returns>A tuple containing (Played count, Total count).</returns>
|
||||
(int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId);
|
||||
|
||||
/// <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 (e.g., LocalAlternateVersion, LinkedAlternateVersion).</param>
|
||||
/// <returns>List of child item IDs.</returns>
|
||||
IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all artist matches from the db.
|
||||
/// </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>
|
||||
/// 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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ 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();
|
||||
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.ItemId).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
@@ -607,7 +697,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
|
||||
@@ -824,8 +915,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))
|
||||
@@ -1157,6 +1248,8 @@ namespace MediaBrowser.Providers.Manager
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
CollectionFolder.LibraryOptionsUpdated -= OnLibraryOptionsUpdated;
|
||||
|
||||
if (!_disposeCancellationTokenSource.IsCancellationRequested)
|
||||
{
|
||||
_disposeCancellationTokenSource.Cancel();
|
||||
@@ -1168,5 +1261,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo>
|
||||
}
|
||||
else
|
||||
{
|
||||
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray();
|
||||
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.ItemId).ToArray();
|
||||
}
|
||||
|
||||
if (replaceData || targetItem.Shares.Count == 0)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ public interface IJellyfinDatabaseProvider
|
||||
/// </summary>
|
||||
IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the descendant query provider for this database type.
|
||||
/// Used for recursive CTE queries to find all descendants of an item.
|
||||
/// </summary>
|
||||
IDescendantQueryProvider DescendantQueryProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initialises jellyfins EFCore database access.
|
||||
/// </summary>
|
||||
|
||||
@@ -39,6 +39,9 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
|
||||
/// <inheritdoc/>
|
||||
public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDescendantQueryProvider DescendantQueryProvider { get; } = new SqliteDescendantQueryProvider();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user