Merge pull request #17466 from Shadowghost/fix-byname-queries
Improve People deduplication, fix search and restrict ItemByName responses
This commit is contained in:
@@ -118,7 +118,7 @@ public class SearchManager : ISearchManager
|
||||
var user = _userManager.GetUserById(query.UserId.Value);
|
||||
if (user is not null)
|
||||
{
|
||||
results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
|
||||
results = await FilterByUserAccessAsync(results, user, query, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,13 +128,14 @@ public class SearchManager : ISearchManager
|
||||
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
||||
IReadOnlyList<SearchResult> candidates,
|
||||
User user,
|
||||
SearchProviderQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
|
||||
// TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
|
||||
// because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
|
||||
var accessFilter = new InternalItemsQuery(user);
|
||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
// SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds
|
||||
// for the user's accessible libraries. The candidate ids are applied to the query below
|
||||
// rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when
|
||||
// ItemIds is non-empty.
|
||||
var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager);
|
||||
|
||||
Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the access filter that decides which items a search may return for a user.
|
||||
/// </summary>
|
||||
internal static class SearchQueryAccessFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds an access filter carrying the search's library access and type filters.
|
||||
/// </summary>
|
||||
/// <param name="user">The user the search runs for.</param>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <returns>The access filter.</returns>
|
||||
public static InternalItemsQuery Build(User user, SearchProviderQuery query, ILibraryManager libraryManager)
|
||||
{
|
||||
// The type filters have to travel with the access filter: a by-name item belongs to no
|
||||
// library, so it carries no TopParentId to match, and the library filter only knows to
|
||||
// exempt it when the query says those types are wanted. A search scoped to a parent gets
|
||||
// no exemption because a by-name item has no parent to descend from either.
|
||||
var accessFilter = new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = query.IncludeItemTypes,
|
||||
ExcludeItemTypes = query.ExcludeItemTypes,
|
||||
IncludeItemsByName = !query.ParentId.HasValue || query.ParentId.Value.IsEmpty()
|
||||
};
|
||||
|
||||
// ConfigureUserAccess populates TopParentIds for the libraries the user may open.
|
||||
libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
|
||||
return accessFilter;
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ public class SqlSearchProvider : IInternalSearchProvider
|
||||
dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
|
||||
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
|
||||
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
|
||||
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId);
|
||||
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query);
|
||||
|
||||
// Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
|
||||
// the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
|
||||
@@ -196,8 +196,9 @@ public class SqlSearchProvider : IInternalSearchProvider
|
||||
private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
|
||||
JellyfinDbContext dbContext,
|
||||
IQueryable<BaseItemEntity> query,
|
||||
Guid? userId)
|
||||
SearchProviderQuery searchQuery)
|
||||
{
|
||||
var userId = searchQuery.UserId;
|
||||
if (!userId.HasValue || userId.Value.IsEmpty())
|
||||
{
|
||||
return query;
|
||||
@@ -209,8 +210,7 @@ public class SqlSearchProvider : IInternalSearchProvider
|
||||
return query;
|
||||
}
|
||||
|
||||
var accessFilter = new InternalItemsQuery(user);
|
||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager);
|
||||
return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
@@ -103,6 +104,7 @@ public class PersonsController : BaseJellyfinApiController
|
||||
personTypes,
|
||||
excludePersonTypes)
|
||||
{
|
||||
AccessFilter = BuildAccessFilter(user),
|
||||
NameContains = searchTerm,
|
||||
NameStartsWith = nameStartsWith,
|
||||
NameLessThan = nameLessThan,
|
||||
@@ -123,6 +125,20 @@ public class PersonsController : BaseJellyfinApiController
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
// People are not owned by a library, so nothing in the Peoples table says which of them a user is
|
||||
// allowed to see; that only follows from the items they are credited on.
|
||||
private InternalItemsQuery? BuildAccessFilter(User? user)
|
||||
{
|
||||
if (user is null || !user.HasContentRestrictions())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var accessFilter = new InternalItemsQuery(user) { IncludeOwnedItems = true };
|
||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
return accessFilter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get person by name.
|
||||
/// </summary>
|
||||
|
||||
@@ -162,6 +162,23 @@ public static class UserEntityExtensions
|
||||
return Array.IndexOf(GetPreferenceValues<Guid>(entity, PreferenceKind.GroupedFolders), id) != -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether any library, parental rating or tag rule keeps content from this user.
|
||||
/// </summary>
|
||||
/// <param name="entity">The user to check.</param>
|
||||
/// <returns><c>True</c> if some content in the library is hidden from this user.</returns>
|
||||
public static bool HasContentRestrictions(this User entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
|
||||
return !entity.HasPermission(PermissionKind.EnableAllFolders)
|
||||
|| entity.GetPreference(PreferenceKind.BlockedMediaFolders).Length > 0
|
||||
|| entity.MaxParentalRatingScore.HasValue
|
||||
|| entity.GetPreference(PreferenceKind.BlockedTags).Length > 0
|
||||
|| entity.GetPreference(PreferenceKind.AllowedTags).Length > 0
|
||||
|| entity.GetPreference(PreferenceKind.BlockUnratedItems).Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the default permissions for a user. Should only be called on user creation.
|
||||
/// </summary>
|
||||
|
||||
@@ -428,6 +428,17 @@ public sealed partial class BaseItemRepository
|
||||
return ApplyAccessFiltering(context, baseQuery, filter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the user restricts access to items by parental rating or tags.
|
||||
/// </summary>
|
||||
/// <param name="filter">The query filter.</param>
|
||||
/// <returns><c>true</c> if the query carries parental restrictions.</returns>
|
||||
private static bool RequiresParentalRestrictions(InternalItemsQuery filter)
|
||||
=> filter.IncludeInheritedTags.Length > 0
|
||||
|| filter.ExcludeInheritedTags.Length > 0
|
||||
|| filter.MaxParentalRating is not null
|
||||
|| filter.BlockUnratedItems.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Applies user access filtering to a query.
|
||||
/// Includes TopParentIds, parental rating, and tag filtering.
|
||||
@@ -438,13 +449,127 @@ public sealed partial class BaseItemRepository
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
InternalItemsQuery filter)
|
||||
{
|
||||
// Apply TopParentIds filtering (library folder access)
|
||||
if (filter.TopParentIds.Length > 0)
|
||||
baseQuery = ApplyTopParentFiltering(context, baseQuery, filter);
|
||||
|
||||
baseQuery = ApplyParentalRestrictions(context, baseQuery, filter);
|
||||
|
||||
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
|
||||
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
|
||||
if (!filter.IncludeOwnedItems)
|
||||
{
|
||||
var topParentIds = filter.TopParentIds;
|
||||
baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value));
|
||||
baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restricts a query to the libraries the user may open, exempting requested by-name items.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="baseQuery">The query to filter.</param>
|
||||
/// <param name="filter">The query filter.</param>
|
||||
/// <returns>The filtered query.</returns>
|
||||
private IQueryable<BaseItemEntity> ApplyTopParentFiltering(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
InternalItemsQuery filter)
|
||||
{
|
||||
var queryTopParentIds = filter.TopParentIds;
|
||||
if (queryTopParentIds.Length == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
var exemptedItemByNameTypes = GetExemptedItemByNameTypes(filter);
|
||||
if (exemptedItemByNameTypes.Count == 0)
|
||||
{
|
||||
return baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
|
||||
}
|
||||
|
||||
baseQuery = baseQuery.Where(e => exemptedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
|
||||
if (filter.UserHasContentRestrictions)
|
||||
{
|
||||
baseQuery = ApplyItemByNameAccessFiltering(baseQuery, context, filter, exemptedItemByNameTypes, queryTopParentIds);
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the by-name types a query asks for, which carry no TopParentId to filter on.
|
||||
/// </summary>
|
||||
/// <param name="filter">The query filter.</param>
|
||||
/// <returns>The type names exempt from library filtering.</returns>
|
||||
private List<string> GetExemptedItemByNameTypes(InternalItemsQuery filter)
|
||||
{
|
||||
var includedItemByNameTypes = GetItemByNameTypesInQuery(filter);
|
||||
if ((filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0)
|
||||
{
|
||||
return includedItemByNameTypes;
|
||||
}
|
||||
|
||||
return _itemByNameKinds.Where(filter.IncludeItemTypes.Contains).Select(e => _itemTypeLookup.BaseItemKindNames[e]!).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a by-name row only when at least one item behind its name is reachable for the user.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The query to filter.</param>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="filter">The query filter.</param>
|
||||
/// <param name="itemByNameTypes">The exempted by-name type names.</param>
|
||||
/// <param name="topParentIds">The libraries the user may open.</param>
|
||||
/// <returns>The filtered query.</returns>
|
||||
private IQueryable<BaseItemEntity> ApplyItemByNameAccessFiltering(
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
JellyfinDbContext context,
|
||||
InternalItemsQuery filter,
|
||||
IReadOnlyList<string> itemByNameTypes,
|
||||
Guid[] topParentIds)
|
||||
{
|
||||
// IncludeOwnedItems: a credit on an alternate version of a reachable movie still counts.
|
||||
var accessibleItems = ApplyAccessFiltering(
|
||||
context,
|
||||
context.BaseItems.AsNoTracking(),
|
||||
new InternalItemsQuery(filter.User) { TopParentIds = topParentIds, IncludeOwnedItems = true });
|
||||
|
||||
var personType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
|
||||
if (itemByNameTypes.Contains(personType))
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Type != personType
|
||||
|| context.Peoples.Any(p => p.Name == e.Name
|
||||
&& context.PeopleBaseItemMap.Any(m => m.PeopleId == p.Id && accessibleItems.Any(i => i.Id == m.ItemId))));
|
||||
}
|
||||
|
||||
foreach (var (kind, valueTypes) in _itemByNameValueTypes)
|
||||
{
|
||||
var typeName = _itemTypeLookup.BaseItemKindNames[kind];
|
||||
if (!itemByNameTypes.Contains(typeName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
baseQuery = baseQuery.Where(e => e.Type != typeName
|
||||
|| context.ItemValues.Any(v => valueTypes.Contains(v.Type) && v.CleanValue == e.CleanName
|
||||
&& context.ItemValuesMap.Any(m => m.ItemValueId == v.ItemValueId && accessibleItems.Any(i => i.Id == m.ItemId))));
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the user's parental rating and tag restrictions to a query.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="baseQuery">The query to filter.</param>
|
||||
/// <param name="filter">The query filter.</param>
|
||||
/// <returns>The filtered query.</returns>
|
||||
private IQueryable<BaseItemEntity> ApplyParentalRestrictions(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
InternalItemsQuery filter)
|
||||
{
|
||||
// Apply parental rating filtering
|
||||
if (filter.MaxParentalRating is not null)
|
||||
{
|
||||
@@ -495,13 +620,6 @@ public sealed partial class BaseItemRepository
|
||||
|| e.Type == personTypeName);
|
||||
}
|
||||
|
||||
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
|
||||
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
|
||||
if (!filter.IncludeOwnedItems)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,10 @@ public sealed partial class BaseItemRepository
|
||||
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id));
|
||||
}
|
||||
|
||||
var orderedAlbums = topAlbumsQuery
|
||||
// The album is what gets returned, and neither branch above reads it through the
|
||||
// user's filters, so its own parental restrictions have to be applied here: a
|
||||
// matching track does not make an album the user may not see visible.
|
||||
var orderedAlbums = ApplyParentalRestrictions(context, topAlbumsQuery, filter)
|
||||
.OrderByDescending(album => album.DateCreated)
|
||||
.ThenByDescending(album => album.Id);
|
||||
|
||||
@@ -422,6 +425,40 @@ public sealed partial class BaseItemRepository
|
||||
seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisodeId));
|
||||
}
|
||||
|
||||
// Step 5b: A container is what gets returned, so it has to pass the user's access
|
||||
// filters on its own - a matching episode does not make a Season or Series the user
|
||||
// may not see visible. Containers that don't pass are replaced by their episode.
|
||||
if (RequiresParentalRestrictions(filter) && entitiesToFetch.Count > 0)
|
||||
{
|
||||
var allowedContainerIds = ApplyParentalRestrictions(
|
||||
context,
|
||||
context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)),
|
||||
filter)
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet();
|
||||
|
||||
for (var i = 0; i < seriesResults.Count; i++)
|
||||
{
|
||||
var (seasonId, seriesId, maxDate, mostRecentEpisodeId) = seriesResults[i];
|
||||
if (seasonId.HasValue && !allowedContainerIds.Contains(seasonId.Value))
|
||||
{
|
||||
seasonId = null;
|
||||
}
|
||||
|
||||
if (seriesId.HasValue && !allowedContainerIds.Contains(seriesId.Value))
|
||||
{
|
||||
seriesId = null;
|
||||
}
|
||||
|
||||
if (seasonId is null && seriesId is null)
|
||||
{
|
||||
entitiesToFetch.Add(mostRecentEpisodeId);
|
||||
}
|
||||
|
||||
seriesResults[i] = (seasonId, seriesId, maxDate, mostRecentEpisodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Fetch the Season/Series entities we decided to return
|
||||
var entities = entitiesToFetch.Count > 0
|
||||
? ApplyNavigations(
|
||||
|
||||
@@ -995,21 +995,7 @@ public sealed partial class BaseItemRepository
|
||||
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TvdbProviderName));
|
||||
}
|
||||
|
||||
var queryTopParentIds = filter.TopParentIds;
|
||||
|
||||
if (queryTopParentIds.Length > 0)
|
||||
{
|
||||
var includedItemByNameTypes = GetItemByNameTypesInQuery(filter);
|
||||
var enableItemsByName = (filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0;
|
||||
if (enableItemsByName && includedItemByNameTypes.Count > 0)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => includedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
|
||||
}
|
||||
}
|
||||
baseQuery = ApplyTopParentFiltering(context, baseQuery, filter);
|
||||
|
||||
if (filter.AncestorIds.Length > 0)
|
||||
{
|
||||
|
||||
@@ -46,6 +46,23 @@ public sealed partial class BaseItemRepository
|
||||
private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios];
|
||||
private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre];
|
||||
|
||||
private static readonly BaseItemKind[] _itemByNameKinds =
|
||||
[
|
||||
BaseItemKind.Person,
|
||||
BaseItemKind.Genre,
|
||||
BaseItemKind.MusicGenre,
|
||||
BaseItemKind.MusicArtist,
|
||||
BaseItemKind.Studio
|
||||
];
|
||||
|
||||
private static readonly (BaseItemKind Kind, IReadOnlyList<ItemValueType> ValueTypes)[] _itemByNameValueTypes =
|
||||
[
|
||||
(BaseItemKind.Genre, _getGenreValueTypes),
|
||||
(BaseItemKind.MusicGenre, _getGenreValueTypes),
|
||||
(BaseItemKind.MusicArtist, _getAllArtistsValueTypes),
|
||||
(BaseItemKind.Studio, _getStudiosValueTypes)
|
||||
];
|
||||
|
||||
// The only folder kinds whose children form a single viewing sequence, so playback progress on a
|
||||
// child rolls up to them. Every other folder kind is a container that cannot be resumed.
|
||||
private static readonly BaseItemKind[] _resumableFolderKinds =
|
||||
|
||||
@@ -21,10 +21,11 @@ namespace Jellyfin.Server.Implementations.Item;
|
||||
/// </summary>
|
||||
/// <param name="dbProvider">Efcore Factory.</param>
|
||||
/// <param name="itemTypeLookup">Items lookup service.</param>
|
||||
/// <param name="queryHelpers">Shared item query helpers.</param>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="PeopleRepository"/> class.
|
||||
/// </remarks>
|
||||
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository
|
||||
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, IItemQueryHelpers queryHelpers) : IPeopleRepository
|
||||
{
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider;
|
||||
|
||||
@@ -33,12 +34,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
{
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter);
|
||||
int? distinctNameCount = null;
|
||||
|
||||
// Include PeopleBaseItemMap
|
||||
if (!filter.ItemId.IsEmpty())
|
||||
{
|
||||
dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId))
|
||||
.OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder)
|
||||
.OrderBy(e => e.BaseItems!.Where(m => m.ItemId == filter.ItemId).Min(m => m.ListOrder))
|
||||
.ThenBy(e => e.PersonType)
|
||||
.ThenBy(e => e.Name);
|
||||
}
|
||||
@@ -46,17 +48,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
{
|
||||
// The Peoples table has one row per (Name, PersonType), so the same person can
|
||||
// appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per
|
||||
// name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the
|
||||
// grouping key so case-only duplicates collapse together.
|
||||
var representativeIds = dbQuery
|
||||
.GroupBy(e => e.Name.ToLower())
|
||||
.Select(g => g.Min(e => e.Id));
|
||||
dbQuery = context.Peoples.AsNoTracking()
|
||||
.Where(p => representativeIds.Contains(p.Id))
|
||||
.OrderBy(e => e.Name);
|
||||
// name so /Persons doesn't return the same BaseItem id repeatedly, keeping the
|
||||
// lowest id per lowercased name so case-only duplicates collapse together.
|
||||
var candidates = dbQuery;
|
||||
dbQuery = candidates
|
||||
.Where(p => !candidates.Any(other => other.Name.ToLower() == p.Name.ToLower() && other.Id < p.Id))
|
||||
.OrderBy(e => e.Name.ToLower());
|
||||
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count();
|
||||
}
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
count = distinctNameCount ?? dbQuery.Count();
|
||||
}
|
||||
|
||||
var count = dbQuery.Count();
|
||||
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
|
||||
{
|
||||
dbQuery = dbQuery.Skip(filter.StartIndex.Value);
|
||||
@@ -71,7 +81,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
{
|
||||
StartIndex = filter.StartIndex ?? 0,
|
||||
TotalRecordCount = count,
|
||||
Items = dbQuery.AsEnumerable().Select(Map).ToArray(),
|
||||
Items = dbQuery.AsEnumerable().SelectMany(MapCredits).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,9 +117,17 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
person.Role = person.Role?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
// multiple metadata providers can provide the _same_ person; dedupe case-insensitively.
|
||||
people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
|
||||
var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
|
||||
// Project the values every comparison below needs once, so neither the case folding nor the
|
||||
// enum formatting is repeated per candidate.
|
||||
var credits = people.Select(e => (Person: e, LoweredName: e.Name.ToLowerInvariant(), PersonType: e.Type.ToString(), LoweredRole: e.Role.ToLowerInvariant()));
|
||||
|
||||
// multiple metadata providers can provide the _same_ credit; dedupe case-insensitively.
|
||||
// The role is part of the key because one person can hold several credits of the same type
|
||||
// on an item, e.g. a Writer credited for both the Novel and the Screenplay.
|
||||
var distinctCredits = credits.DistinctBy(e => (e.LoweredName, e.PersonType, e.LoweredRole)).ToArray();
|
||||
|
||||
var distinctPersons = distinctCredits.DistinctBy(e => (e.LoweredName, e.PersonType)).ToArray();
|
||||
var personKeys = distinctPersons.Select(e => e.LoweredName + "-" + e.PersonType).ToArray();
|
||||
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
using var transaction = context.Database.BeginTransaction();
|
||||
@@ -122,23 +140,44 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
.Select(f => f.item)
|
||||
.ToArray();
|
||||
|
||||
var toAdd = people
|
||||
.Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString()))
|
||||
.Select(Map);
|
||||
var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet();
|
||||
|
||||
var toAdd = distinctPersons
|
||||
.Where(e => !existingPersonKeys.Contains((e.LoweredName, e.PersonType)))
|
||||
.Select(e => Map(e.Person))
|
||||
.ToArray();
|
||||
context.Peoples.AddRange(toAdd);
|
||||
context.SaveChanges();
|
||||
|
||||
var personsEntities = toAdd.Concat(existingPersons).ToArray();
|
||||
// The Peoples table can hold case-only duplicates, so keep the first match per key just as
|
||||
// the previous First() lookup did.
|
||||
var personsEntities = new Dictionary<(string LoweredName, string PersonType), People>();
|
||||
foreach (var entity in toAdd.Concat(existingPersons))
|
||||
{
|
||||
personsEntities.TryAdd((entity.Name.ToLowerInvariant(), entity.PersonType ?? string.Empty), entity);
|
||||
}
|
||||
|
||||
var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList();
|
||||
var existingMapsByCredit = new Dictionary<(string LoweredName, string PersonType, string LoweredRole), PeopleBaseItemMap>();
|
||||
foreach (var map in existingMaps)
|
||||
{
|
||||
existingMapsByCredit.TryAdd((map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty), map);
|
||||
}
|
||||
|
||||
var listOrder = 0;
|
||||
|
||||
foreach (var person in people)
|
||||
foreach (var credit in distinctCredits)
|
||||
{
|
||||
var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString());
|
||||
var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
|
||||
if (existingMap is null)
|
||||
var entityPerson = personsEntities[(credit.LoweredName, credit.PersonType)];
|
||||
if (existingMapsByCredit.TryGetValue((credit.LoweredName, credit.PersonType, credit.LoweredRole), out var existingMap))
|
||||
{
|
||||
// Update the order for existing mappings
|
||||
existingMap.ListOrder = listOrder;
|
||||
existingMap.SortOrder = credit.Person.SortOrder;
|
||||
// person mapping already exists so remove from list
|
||||
existingMaps.Remove(existingMap);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
|
||||
{
|
||||
@@ -147,18 +186,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
People = null!,
|
||||
PeopleId = entityPerson.Id,
|
||||
ListOrder = listOrder,
|
||||
SortOrder = person.SortOrder,
|
||||
Role = person.Role
|
||||
SortOrder = credit.Person.SortOrder,
|
||||
Role = credit.Person.Role
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update the order for existing mappings
|
||||
existingMap.ListOrder = listOrder;
|
||||
existingMap.SortOrder = person.SortOrder;
|
||||
// person mapping already exists so remove from list
|
||||
existingMaps.Remove(existingMap);
|
||||
}
|
||||
|
||||
listOrder++;
|
||||
}
|
||||
@@ -205,9 +236,19 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
return result;
|
||||
}
|
||||
|
||||
private PersonInfo Map(People people)
|
||||
private IEnumerable<PersonInfo> MapCredits(People people)
|
||||
{
|
||||
var mappings = people.BaseItems;
|
||||
if (mappings is null || mappings.Count == 0)
|
||||
{
|
||||
return [Map(people, null)];
|
||||
}
|
||||
|
||||
return mappings.OrderBy(m => m.ListOrder).Select(m => Map(people, m));
|
||||
}
|
||||
|
||||
private PersonInfo Map(People people, PeopleBaseItemMap? mapping)
|
||||
{
|
||||
var mapping = people.BaseItems?.FirstOrDefault();
|
||||
var personInfo = new PersonInfo()
|
||||
{
|
||||
Id = people.Id,
|
||||
@@ -240,13 +281,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
if (filter.User is not null && filter.IsFavorite.HasValue)
|
||||
{
|
||||
var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
|
||||
var oldQuery = query;
|
||||
var userId = filter.User.Id;
|
||||
var isFavorite = filter.IsFavorite.Value;
|
||||
var favoriteItemIds = context.UserData
|
||||
.Where(u => u.UserId.Equals(userId) && u.IsFavorite == isFavorite)
|
||||
.Select(u => u.ItemId);
|
||||
|
||||
query = context.UserData
|
||||
.Where(u => u.Item!.Type == personType && u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id))
|
||||
.Join(oldQuery, e => e.Item!.Name, e => e.Name, (item, person) => person)
|
||||
.Distinct()
|
||||
.AsNoTracking();
|
||||
var favoriteNames = context.BaseItems
|
||||
.Where(b => b.Type == personType && favoriteItemIds.Contains(b.Id))
|
||||
.Select(b => b.Name);
|
||||
|
||||
query = query.Where(e => favoriteNames.Contains(e.Name));
|
||||
}
|
||||
|
||||
if (filter.AccessFilter is not null)
|
||||
{
|
||||
// Keep only people credited on at least one item the user can see.
|
||||
var accessibleItems = queryHelpers.ApplyAccessFiltering(context, context.BaseItems.AsNoTracking(), filter.AccessFilter);
|
||||
query = query.Where(e => context.PeopleBaseItemMap
|
||||
.Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId)));
|
||||
}
|
||||
|
||||
if (!filter.ItemId.IsEmpty())
|
||||
|
||||
@@ -496,6 +496,12 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
public IReadOnlyList<string> SubtitleLanguages { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether some content in the library is hidden from <see cref="User"/>.
|
||||
/// Filters that only exist to hide content can be skipped entirely when this is false.
|
||||
/// </summary>
|
||||
public bool UserHasContentRestrictions { get; private set; }
|
||||
|
||||
public void SetUser(User user)
|
||||
{
|
||||
var maxRating = user.MaxParentalRatingScore;
|
||||
@@ -519,6 +525,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
.Select(tag => tag.RemoveDiacritics().ToLowerInvariant())
|
||||
.ToArray();
|
||||
|
||||
UserHasContentRestrictions = user.HasContentRestrictions();
|
||||
User = user;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,16 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
PersonTypes = personTypes;
|
||||
ExcludePersonTypes = excludePersonTypes;
|
||||
EnableTotalRecordCount = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to count the matching people. Under an
|
||||
/// <see cref="AccessFilter"/> the count is the expensive half of the query: the page walk stops
|
||||
/// at the limit, the count has to check every person.
|
||||
/// </summary>
|
||||
public bool EnableTotalRecordCount { get; set; }
|
||||
|
||||
public int? StartIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -51,5 +59,11 @@ namespace MediaBrowser.Controller.Entities
|
||||
public User User { get; set; }
|
||||
|
||||
public bool? IsFavorite { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item query whose access settings (library access, parental rating, tags)
|
||||
/// people must satisfy through at least one of the items they are credited on.
|
||||
/// </summary>
|
||||
public InternalItemsQuery AccessFilter { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,57 +35,61 @@ namespace MediaBrowser.Controller.Entities
|
||||
person.Type = PersonKind.Writer;
|
||||
}
|
||||
|
||||
// If the type is GuestStar and there's already an Actor entry, then update it to avoid dupes
|
||||
if (person.Type == PersonKind.GuestStar)
|
||||
{
|
||||
var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && p.Type == PersonKind.Actor);
|
||||
// Check for dupes based on the combination of Name, Type and Role.
|
||||
var existing = people.FirstOrDefault(p => IsSameCredit(p, person)
|
||||
&& string.Equals(p.Role ?? string.Empty, person.Role ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Type = PersonKind.GuestStar;
|
||||
MergeExisting(existing, person);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (person.Type == PersonKind.Actor)
|
||||
if (existing is null)
|
||||
{
|
||||
// If the actor already exists without a role and we have one, fill it in
|
||||
var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && (p.Type == PersonKind.Actor || p.Type == PersonKind.GuestStar));
|
||||
if (existing is null)
|
||||
if (string.IsNullOrEmpty(person.Role))
|
||||
{
|
||||
// Wasn't there - add it
|
||||
people.Add(person);
|
||||
existing = people.FirstOrDefault(p => IsSameCredit(p, person));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Was there, if no role and we have one - fill it in
|
||||
if (string.IsNullOrEmpty(existing.Role) && !string.IsNullOrEmpty(person.Role))
|
||||
// If the person already exists without a role and we have one, fill it in
|
||||
existing = people.FirstOrDefault(p => IsSameCredit(p, person) && string.IsNullOrEmpty(p.Role));
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Role = person.Role;
|
||||
}
|
||||
|
||||
MergeExisting(existing, person);
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
var existing = people.FirstOrDefault(p =>
|
||||
string.Equals(p.Name, person.Name, StringComparison.OrdinalIgnoreCase)
|
||||
&& p.Type == person.Type);
|
||||
|
||||
// Check for dupes based on the combination of Name and Type
|
||||
if (existing is null)
|
||||
{
|
||||
people.Add(person);
|
||||
}
|
||||
else
|
||||
{
|
||||
MergeExisting(existing, person);
|
||||
}
|
||||
people.Add(person);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the type is GuestStar and there's already an Actor entry, then promote it to avoid dupes
|
||||
if (person.Type == PersonKind.GuestStar)
|
||||
{
|
||||
existing.Type = PersonKind.GuestStar;
|
||||
}
|
||||
|
||||
MergeExisting(existing, person);
|
||||
}
|
||||
|
||||
private static bool IsSameCredit(PersonInfo existing, PersonInfo person)
|
||||
{
|
||||
if (!string.Equals(existing.Name, person.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Actor and GuestStar describe the same credit, a guest star is just a promoted actor.
|
||||
if (IsCastKind(existing.Type) && IsCastKind(person.Type))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return existing.Type == person.Type;
|
||||
}
|
||||
|
||||
private static bool IsCastKind(PersonKind kind)
|
||||
=> kind is PersonKind.Actor or PersonKind.GuestStar;
|
||||
|
||||
private static void MergeExisting(PersonInfo existing, PersonInfo person)
|
||||
{
|
||||
existing.SortOrder = person.SortOrder ?? existing.SortOrder;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ public class PeopleBaseItemMapConfiguration : IEntityTypeConfiguration<PeopleBas
|
||||
builder.HasKey(e => new { e.ItemId, e.PeopleId, e.Role });
|
||||
builder.HasIndex(e => new { e.ItemId, e.SortOrder });
|
||||
builder.HasIndex(e => new { e.ItemId, e.ListOrder });
|
||||
builder.HasIndex(e => e.PeopleId);
|
||||
builder.HasIndex(e => new { e.PeopleId, e.ItemId });
|
||||
builder.HasOne(e => e.Item);
|
||||
builder.HasOne(e => e.People);
|
||||
}
|
||||
|
||||
+2035
File diff suppressed because it is too large
Load Diff
+25
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPeopleNameLowerIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Expression index, so it cannot be declared on the entity type. /Persons collapses the
|
||||
// one-row-per-(Name, PersonType) table to one row per lowercased name; without this index
|
||||
// that dedup scans and groups the whole table on every request.
|
||||
migrationBuilder.Sql("CREATE INDEX IF NOT EXISTS \"IX_Peoples_NameLower\" ON \"Peoples\" (lower(\"Name\"));");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DROP INDEX IF EXISTS \"IX_Peoples_NameLower\";");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1813
File diff suppressed because it is too large
Load Diff
+36
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPeopleItemMapCoveringIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_PeopleBaseItemMap_PeopleId",
|
||||
table: "PeopleBaseItemMap");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PeopleBaseItemMap_PeopleId_ItemId",
|
||||
table: "PeopleBaseItemMap",
|
||||
columns: new[] { "PeopleId", "ItemId" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_PeopleBaseItemMap_PeopleId_ItemId",
|
||||
table: "PeopleBaseItemMap");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PeopleBaseItemMap_PeopleId",
|
||||
table: "PeopleBaseItemMap",
|
||||
column: "PeopleId");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1058,12 +1058,12 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
|
||||
b.HasKey("ItemId", "PeopleId", "Role");
|
||||
|
||||
b.HasIndex("PeopleId");
|
||||
|
||||
b.HasIndex("ItemId", "ListOrder");
|
||||
|
||||
b.HasIndex("ItemId", "SortOrder");
|
||||
|
||||
b.HasIndex("PeopleId", "ItemId");
|
||||
|
||||
b.ToTable("PeopleBaseItemMap");
|
||||
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable
|
||||
{
|
||||
private static readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly PeopleRepository _repository;
|
||||
|
||||
public PeopleRepositoryUpdatePeopleTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
var itemTypeLookup = new ItemTypeLookup();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
ctx.Database.EnsureCreated();
|
||||
ctx.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _itemId,
|
||||
Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie],
|
||||
Name = "Movie",
|
||||
MediaType = "Video",
|
||||
IsMovie = true,
|
||||
IsFolder = false,
|
||||
IsVirtualItem = false
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
|
||||
_repository = new PeopleRepository(
|
||||
factory.Object,
|
||||
itemTypeLookup,
|
||||
new Mock<IItemQueryHelpers>().Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePeople_SamePersonAndTypeWithDifferentRoles_KeepsEveryCredit()
|
||||
{
|
||||
_repository.UpdatePeople(_itemId, [
|
||||
CreatePerson("Person A", PersonKind.Writer, "Novel"),
|
||||
CreatePerson("Person A", PersonKind.Writer, "Screenplay")
|
||||
]);
|
||||
|
||||
using var ctx = CreateDbContext();
|
||||
Assert.Single(ctx.Peoples);
|
||||
Assert.Equal(
|
||||
["Novel", "Screenplay"],
|
||||
ctx.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePeople_CreditsDifferingOnlyInCase_AreDeduped()
|
||||
{
|
||||
_repository.UpdatePeople(_itemId, [
|
||||
CreatePerson("Person A", PersonKind.Actor, "Hero"),
|
||||
CreatePerson("person a", PersonKind.Actor, "hero")
|
||||
]);
|
||||
|
||||
using var ctx = CreateDbContext();
|
||||
Assert.Single(ctx.Peoples);
|
||||
var map = Assert.Single(ctx.PeopleBaseItemMap);
|
||||
Assert.Equal("Hero", map.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePeople_SamePersonAsDifferentTypes_CreatesOnePersonPerType()
|
||||
{
|
||||
_repository.UpdatePeople(_itemId, [
|
||||
CreatePerson("Person A", PersonKind.Actor, "Hero"),
|
||||
CreatePerson("Person A", PersonKind.Director, string.Empty)
|
||||
]);
|
||||
|
||||
using var ctx = CreateDbContext();
|
||||
Assert.Equal(2, ctx.Peoples.Count());
|
||||
Assert.Equal(2, ctx.PeopleBaseItemMap.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePeople_RepeatedUpdate_ReusesMappingsAndRefreshesOrder()
|
||||
{
|
||||
_repository.UpdatePeople(_itemId, [
|
||||
CreatePerson("Person A", PersonKind.Actor, "Hero"),
|
||||
CreatePerson("Person B", PersonKind.Actor, "Sidekick")
|
||||
]);
|
||||
|
||||
Guid[] peopleIdsBefore;
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
peopleIdsBefore = ctx.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray();
|
||||
}
|
||||
|
||||
// Reversed order, so the list order of both mappings has to be rewritten.
|
||||
_repository.UpdatePeople(_itemId, [
|
||||
CreatePerson("Person B", PersonKind.Actor, "Sidekick"),
|
||||
CreatePerson("Person A", PersonKind.Actor, "Hero")
|
||||
]);
|
||||
|
||||
using var after = CreateDbContext();
|
||||
Assert.Equal(peopleIdsBefore, after.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray());
|
||||
Assert.Equal(
|
||||
["Sidekick", "Hero"],
|
||||
after.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePeople_CreditRemoved_DropsOnlyThatMapping()
|
||||
{
|
||||
_repository.UpdatePeople(_itemId, [
|
||||
CreatePerson("Person A", PersonKind.Writer, "Novel"),
|
||||
CreatePerson("Person A", PersonKind.Writer, "Screenplay")
|
||||
]);
|
||||
|
||||
_repository.UpdatePeople(_itemId, [
|
||||
CreatePerson("Person A", PersonKind.Writer, "Novel")
|
||||
]);
|
||||
|
||||
using var ctx = CreateDbContext();
|
||||
var map = Assert.Single(ctx.PeopleBaseItemMap);
|
||||
Assert.Equal("Novel", map.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePeople_RoleCaseChanged_KeepsExistingMapping()
|
||||
{
|
||||
_repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
|
||||
|
||||
_repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "HERO")]);
|
||||
|
||||
using var ctx = CreateDbContext();
|
||||
var map = Assert.Single(ctx.PeopleBaseItemMap);
|
||||
Assert.Equal("Hero", map.Role);
|
||||
}
|
||||
|
||||
private static PersonInfo CreatePerson(string name, PersonKind type, string role)
|
||||
{
|
||||
return new PersonInfo
|
||||
{
|
||||
Name = name,
|
||||
Type = type,
|
||||
Role = role
|
||||
};
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user