aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server.Implementations/Item/PeopleRepository.cs')
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs190
1 files changed, 147 insertions, 43 deletions
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index eb87b525fe..a592d0e6e2 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -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(),
};
}
@@ -79,7 +89,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter)
{
using var context = _dbProvider.CreateDbContext();
- var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct();
+
+ IQueryable<string> dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter)
+ .Select(e => e.Name)
+ .Distinct()
+ .OrderBy(e => e);
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
{
@@ -88,7 +102,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.Limit > 0)
{
- dbQuery = dbQuery.OrderBy(e => e).Take(filter.Limit);
+ dbQuery = dbQuery.Take(filter.Limit);
}
return dbQuery.ToArray();
@@ -103,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();
@@ -118,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()
{
@@ -143,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++;
}
@@ -201,9 +236,66 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
return result;
}
- private PersonInfo Map(People people)
+ /// <inheritdoc/>
+ public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds)
+ {
+ using var context = _dbProvider.CreateDbContext();
+ var rows = context.PeopleBaseItemMap
+ .AsNoTracking()
+ .Where(m => itemIds.Contains(m.ItemId))
+ .OrderBy(m => m.ListOrder)
+ .Select(m => new
+ {
+ m.ItemId,
+ m.Role,
+ m.SortOrder,
+ m.People.Id,
+ m.People.Name,
+ m.People.PersonType
+ })
+ .ToList();
+
+ var result = new Dictionary<Guid, IReadOnlyList<PersonInfo>>();
+ foreach (var group in rows.GroupBy(r => r.ItemId))
+ {
+ var people = new List<PersonInfo>();
+ foreach (var row in group)
+ {
+ var personInfo = new PersonInfo
+ {
+ ItemId = row.ItemId,
+ Id = row.Id,
+ Name = row.Name,
+ Role = row.Role,
+ SortOrder = row.SortOrder
+ };
+ if (Enum.TryParse<PersonKind>(row.PersonType, out var kind))
+ {
+ personInfo.Type = kind;
+ }
+
+ people.Add(personInfo);
+ }
+
+ result[group.Key] = people;
+ }
+
+ return result;
+ }
+
+ 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,
@@ -236,13 +328,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())