< Summary - Jellyfin

Information
Class: Jellyfin.Server.Implementations.Item.PeopleRepository
Assembly: Jellyfin.Server.Implementations
File(s): /srv/git/jellyfin/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
Line coverage
28%
Covered lines: 61
Uncovered lines: 153
Coverable lines: 214
Total lines: 431
Line coverage: 28.5%
Branch coverage
16%
Covered branches: 16
Total branches: 100
Branch coverage: 16%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/6/2026 - 12:15:23 AM Line coverage: 0.7% (1/133) Branch coverage: 0% (0/70) Total lines: 2885/7/2026 - 12:15:44 AM Line coverage: 0.7% (1/138) Branch coverage: 0% (0/70) Total lines: 2965/11/2026 - 12:15:59 AM Line coverage: 0.7% (1/138) Branch coverage: 0% (0/70) Total lines: 2975/14/2026 - 12:15:54 AM Line coverage: 0.7% (1/136) Branch coverage: 0% (0/66) Total lines: 2915/28/2026 - 12:15:50 AM Line coverage: 0.6% (1/149) Branch coverage: 0% (0/70) Total lines: 3166/1/2026 - 12:16:05 AM Line coverage: 0.6% (1/157) Branch coverage: 0% (0/72) Total lines: 3277/27/2026 - 12:16:14 AM Line coverage: 0.6% (1/160) Branch coverage: 0% (0/72) Total lines: 3318/6/2026 - 12:17:15 AM Line coverage: 33.7% (61/181) Branch coverage: 17% (16/94) Total lines: 3848/9/2026 - 12:16:58 AM Line coverage: 28.5% (61/214) Branch coverage: 16% (16/100) Total lines: 431 5/6/2026 - 12:15:23 AM Line coverage: 0.7% (1/133) Branch coverage: 0% (0/70) Total lines: 2885/7/2026 - 12:15:44 AM Line coverage: 0.7% (1/138) Branch coverage: 0% (0/70) Total lines: 2965/11/2026 - 12:15:59 AM Line coverage: 0.7% (1/138) Branch coverage: 0% (0/70) Total lines: 2975/14/2026 - 12:15:54 AM Line coverage: 0.7% (1/136) Branch coverage: 0% (0/66) Total lines: 2915/28/2026 - 12:15:50 AM Line coverage: 0.6% (1/149) Branch coverage: 0% (0/70) Total lines: 3166/1/2026 - 12:16:05 AM Line coverage: 0.6% (1/157) Branch coverage: 0% (0/72) Total lines: 3277/27/2026 - 12:16:14 AM Line coverage: 0.6% (1/160) Branch coverage: 0% (0/72) Total lines: 3318/6/2026 - 12:17:15 AM Line coverage: 33.7% (61/181) Branch coverage: 17% (16/94) Total lines: 3848/9/2026 - 12:16:58 AM Line coverage: 28.5% (61/214) Branch coverage: 16% (16/100) Total lines: 431

Coverage delta

Coverage delta 34 -34

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetPeople(...)0%210140%
GetPeopleNames(...)0%4260%
UpdatePeople(...)72.72%2222100%
GetPeopleNamesByItems(...)0%4260%
GetPeopleByItems(...)0%4260%
MapCredits(...)0%2040%
Map(...)0%4260%
Map(...)100%11100%
TranslateQuery(...)0%812280%
IsAlphaNumeric(...)0%7280%
IsValidPersonType(...)100%210%

File(s)

/srv/git/jellyfin/Jellyfin.Server.Implementations/Item/PeopleRepository.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using Jellyfin.Data.Enums;
 5using Jellyfin.Database.Implementations;
 6using Jellyfin.Database.Implementations.Entities;
 7using Jellyfin.Extensions;
 8using MediaBrowser.Controller.Entities;
 9using MediaBrowser.Controller.Persistence;
 10using MediaBrowser.Model.Querying;
 11using Microsoft.EntityFrameworkCore;
 12
 13namespace Jellyfin.Server.Implementations.Item;
 14#pragma warning disable RS0030 // Do not use banned APIs
 15#pragma warning disable CA1304 // Specify CultureInfo
 16#pragma warning disable CA1311 // Specify a culture or use an invariant version
 17#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string compari
 18
 19/// <summary>
 20/// Manager for handling people.
 21/// </summary>
 22/// <param name="dbProvider">Efcore Factory.</param>
 23/// <param name="itemTypeLookup">Items lookup service.</param>
 24/// <param name="queryHelpers">Shared item query helpers.</param>
 25/// <remarks>
 26/// Initializes a new instance of the <see cref="PeopleRepository"/> class.
 27/// </remarks>
 28public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, IItemQuer
 29{
 2830    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider;
 31
 32    /// <inheritdoc/>
 33    public QueryResult<PersonInfo> GetPeople(InternalPeopleQuery filter)
 34    {
 035        using var context = _dbProvider.CreateDbContext();
 036        var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter);
 037        int? distinctNameCount = null;
 38
 39        // Include PeopleBaseItemMap
 040        if (!filter.ItemId.IsEmpty())
 41        {
 042            dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId))
 043                .OrderBy(e => e.BaseItems!.Where(m => m.ItemId == filter.ItemId).Min(m => m.ListOrder))
 044                .ThenBy(e => e.PersonType)
 045                .ThenBy(e => e.Name);
 46        }
 47        else
 48        {
 49            // The Peoples table has one row per (Name, PersonType), so the same person can
 50            // appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per
 51            // name so /Persons doesn't return the same BaseItem id repeatedly, keeping the
 52            // lowest id per lowercased name so case-only duplicates collapse together.
 053            var candidates = dbQuery;
 054            dbQuery = candidates
 055                .Where(p => !candidates.Any(other => other.Name.ToLower() == p.Name.ToLower() && other.Id < p.Id))
 056                .OrderBy(e => e.Name.ToLower());
 57
 058            if (filter.EnableTotalRecordCount)
 59            {
 060                distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count();
 61            }
 62        }
 63
 064        var count = 0;
 065        if (filter.EnableTotalRecordCount)
 66        {
 067            count = distinctNameCount ?? dbQuery.Count();
 68        }
 69
 070        if (filter.StartIndex.HasValue && filter.StartIndex > 0)
 71        {
 072            dbQuery = dbQuery.Skip(filter.StartIndex.Value);
 73        }
 74
 075        if (filter.Limit > 0)
 76        {
 077            dbQuery = dbQuery.Take(filter.Limit);
 78        }
 79
 080        return new QueryResult<PersonInfo>
 081        {
 082            StartIndex = filter.StartIndex ?? 0,
 083            TotalRecordCount = count,
 084            Items = dbQuery.AsEnumerable().SelectMany(MapCredits).ToArray(),
 085        };
 086    }
 87
 88    /// <inheritdoc/>
 89    public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter)
 90    {
 091        using var context = _dbProvider.CreateDbContext();
 92
 093        IQueryable<string> dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter)
 094            .Select(e => e.Name)
 095            .Distinct()
 096            .OrderBy(e => e);
 97
 098        if (filter.StartIndex.HasValue && filter.StartIndex > 0)
 99        {
 0100            dbQuery = dbQuery.Skip(filter.StartIndex.Value);
 101        }
 102
 0103        if (filter.Limit > 0)
 104        {
 0105            dbQuery = dbQuery.Take(filter.Limit);
 106        }
 107
 0108        return dbQuery.ToArray();
 0109    }
 110
 111    /// <inheritdoc />
 112    public void UpdatePeople(Guid itemId, IReadOnlyList<PersonInfo> people)
 113    {
 48114        foreach (var person in people)
 115        {
 15116            person.Name = person.Name.Trim();
 15117            person.Role = person.Role?.Trim() ?? string.Empty;
 118        }
 119
 120        // Project the values every comparison below needs once, so neither the case folding nor the
 121        // enum formatting is repeated per candidate.
 9122        var credits = people.Select(e => (Person: e, LoweredName: e.Name.ToLowerInvariant(), PersonType: e.Type.ToString
 123
 124        // multiple metadata providers can provide the _same_ credit; dedupe case-insensitively.
 125        // The role is part of the key because one person can hold several credits of the same type
 126        // on an item, e.g. a Writer credited for both the Novel and the Screenplay.
 9127        var distinctCredits = credits.DistinctBy(e => (e.LoweredName, e.PersonType, e.LoweredRole)).ToArray();
 128
 9129        var distinctPersons = distinctCredits.DistinctBy(e => (e.LoweredName, e.PersonType)).ToArray();
 9130        var personKeys = distinctPersons.Select(e => e.LoweredName + "-" + e.PersonType).ToArray();
 131
 9132        using var context = _dbProvider.CreateDbContext();
 9133        using var transaction = context.Database.BeginTransaction();
 9134        var existingPersons = context.Peoples.Select(e => new
 9135        {
 9136            item = e,
 9137            SelectionKey = e.Name.ToLower() + "-" + e.PersonType
 9138        })
 9139            .Where(p => personKeys.Contains(p.SelectionKey))
 9140            .Select(f => f.item)
 9141            .ToArray();
 142
 9143        var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).
 144
 9145        var toAdd = distinctPersons
 9146            .Where(e => !existingPersonKeys.Contains((e.LoweredName, e.PersonType)))
 9147            .Select(e => Map(e.Person))
 9148            .ToArray();
 9149        context.Peoples.AddRange(toAdd);
 9150        context.SaveChanges();
 151
 152        // The Peoples table can hold case-only duplicates, so keep the first match per key just as
 153        // the previous First() lookup did.
 9154        var personsEntities = new Dictionary<(string LoweredName, string PersonType), People>();
 42155        foreach (var entity in toAdd.Concat(existingPersons))
 156        {
 12157            personsEntities.TryAdd((entity.Name.ToLowerInvariant(), entity.PersonType ?? string.Empty), entity);
 158        }
 159
 9160        var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList();
 9161        var existingMapsByCredit = new Dictionary<(string LoweredName, string PersonType, string LoweredRole), PeopleBas
 28162        foreach (var map in existingMaps)
 163        {
 5164            existingMapsByCredit.TryAdd((map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.
 165        }
 166
 9167        var listOrder = 0;
 168
 46169        foreach (var credit in distinctCredits)
 170        {
 14171            var entityPerson = personsEntities[(credit.LoweredName, credit.PersonType)];
 14172            if (existingMapsByCredit.TryGetValue((credit.LoweredName, credit.PersonType, credit.LoweredRole), out var ex
 173            {
 174                // Update the order for existing mappings
 4175                existingMap.ListOrder = listOrder;
 4176                existingMap.SortOrder = credit.Person.SortOrder;
 177                // person mapping already exists so remove from list
 4178                existingMaps.Remove(existingMap);
 179            }
 180            else
 181            {
 10182                context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
 10183                {
 10184                    Item = null!,
 10185                    ItemId = itemId,
 10186                    People = null!,
 10187                    PeopleId = entityPerson.Id,
 10188                    ListOrder = listOrder,
 10189                    SortOrder = credit.Person.SortOrder,
 10190                    Role = credit.Person.Role
 10191                });
 192            }
 193
 14194            listOrder++;
 195        }
 196
 9197        context.PeopleBaseItemMap.RemoveRange(existingMaps);
 198
 9199        context.SaveChanges();
 9200        transaction.Commit();
 18201    }
 202
 203    /// <inheritdoc/>
 204    public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnly
 205    {
 0206        using var context = _dbProvider.CreateDbContext();
 0207        var query = context.PeopleBaseItemMap
 0208            .AsNoTracking()
 0209            .Where(m => itemIds.Contains(m.ItemId));
 210
 0211        if (personTypes.Count > 0)
 212        {
 0213            query = query.Where(m => personTypes.Contains(m.People.PersonType));
 214        }
 215
 0216        var rows = query
 0217            .OrderBy(m => m.ListOrder)
 0218            .Select(m => new { m.ItemId, m.People.Name })
 0219            .ToList();
 220
 0221        var result = new Dictionary<Guid, IReadOnlyList<string>>();
 0222        foreach (var group in rows.GroupBy(r => r.ItemId))
 223        {
 0224            var names = group
 0225                .Select(r => r.Name)
 0226                .Where(name => !string.IsNullOrEmpty(name))
 0227                .Distinct()
 0228                .ToArray();
 229
 0230            if (names.Length > 0)
 231            {
 0232                result[group.Key] = names;
 233            }
 234        }
 235
 0236        return result;
 0237    }
 238
 239    /// <inheritdoc/>
 240    public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds)
 241    {
 0242        using var context = _dbProvider.CreateDbContext();
 0243        var rows = context.PeopleBaseItemMap
 0244            .AsNoTracking()
 0245            .Where(m => itemIds.Contains(m.ItemId))
 0246            .OrderBy(m => m.ListOrder)
 0247            .Select(m => new
 0248            {
 0249                m.ItemId,
 0250                m.Role,
 0251                m.SortOrder,
 0252                m.People.Id,
 0253                m.People.Name,
 0254                m.People.PersonType
 0255            })
 0256            .ToList();
 257
 0258        var result = new Dictionary<Guid, IReadOnlyList<PersonInfo>>();
 0259        foreach (var group in rows.GroupBy(r => r.ItemId))
 260        {
 0261            var people = new List<PersonInfo>();
 0262            foreach (var row in group)
 263            {
 0264                var personInfo = new PersonInfo
 0265                {
 0266                    ItemId = row.ItemId,
 0267                    Id = row.Id,
 0268                    Name = row.Name,
 0269                    Role = row.Role,
 0270                    SortOrder = row.SortOrder
 0271                };
 0272                if (Enum.TryParse<PersonKind>(row.PersonType, out var kind))
 273                {
 0274                    personInfo.Type = kind;
 275                }
 276
 0277                people.Add(personInfo);
 278            }
 279
 0280            result[group.Key] = people;
 281        }
 282
 0283        return result;
 0284    }
 285
 286    private IEnumerable<PersonInfo> MapCredits(People people)
 287    {
 0288        var mappings = people.BaseItems;
 0289        if (mappings is null || mappings.Count == 0)
 290        {
 0291            return [Map(people, null)];
 292        }
 293
 0294        return mappings.OrderBy(m => m.ListOrder).Select(m => Map(people, m));
 295    }
 296
 297    private PersonInfo Map(People people, PeopleBaseItemMap? mapping)
 298    {
 0299        var personInfo = new PersonInfo()
 0300        {
 0301            Id = people.Id,
 0302            Name = people.Name,
 0303            Role = mapping?.Role,
 0304            SortOrder = mapping?.SortOrder
 0305        };
 0306        if (Enum.TryParse<PersonKind>(people.PersonType, out var kind))
 307        {
 0308            personInfo.Type = kind;
 309        }
 310
 0311        return personInfo;
 312    }
 313
 314    private People Map(PersonInfo people)
 315    {
 8316        var personInfo = new People()
 8317        {
 8318            Name = people.Name,
 8319            PersonType = people.Type.ToString(),
 8320            Id = people.Id,
 8321        };
 322
 8323        return personInfo;
 324    }
 325
 326    private IQueryable<People> TranslateQuery(IQueryable<People> query, JellyfinDbContext context, InternalPeopleQuery f
 327    {
 0328        if (filter.User is not null && filter.IsFavorite.HasValue)
 329        {
 0330            var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
 0331            var userId = filter.User.Id;
 0332            var isFavorite = filter.IsFavorite.Value;
 0333            var favoriteItemIds = context.UserData
 0334                .Where(u => u.UserId.Equals(userId) && u.IsFavorite == isFavorite)
 0335                .Select(u => u.ItemId);
 336
 0337            var favoriteNames = context.BaseItems
 0338                .Where(b => b.Type == personType && favoriteItemIds.Contains(b.Id))
 0339                .Select(b => b.Name);
 340
 0341            query = query.Where(e => favoriteNames.Contains(e.Name));
 342        }
 343
 0344        if (filter.AccessFilter is not null)
 345        {
 346            // Keep only people credited on at least one item the user can see.
 0347            var accessibleItems = queryHelpers.ApplyAccessFiltering(context, context.BaseItems.AsNoTracking(), filter.Ac
 0348            query = query.Where(e => context.PeopleBaseItemMap
 0349                .Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId)));
 350        }
 351
 0352        if (!filter.ItemId.IsEmpty())
 353        {
 0354            query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId)));
 355        }
 356
 0357        if (filter.ParentId != null)
 358        {
 0359            query = query.Where(e => e.BaseItems!.Any(w => context.AncestorIds.Any(i => i.ParentItemId == filter.ParentI
 360        }
 361
 0362        if (!filter.AppearsInItemId.IsEmpty())
 363        {
 0364            query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId)));
 365        }
 366
 0367        var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList();
 0368        if (queryPersonTypes.Count > 0)
 369        {
 0370            query = query.Where(e => queryPersonTypes.Contains(e.PersonType));
 371        }
 372
 0373        var queryExcludePersonTypes = filter.ExcludePersonTypes.Where(IsValidPersonType).ToList();
 374
 0375        if (queryExcludePersonTypes.Count > 0)
 376        {
 0377            query = query.Where(e => !queryExcludePersonTypes.Contains(e.PersonType));
 378        }
 379
 0380        if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty())
 381        {
 0382            query = query.Where(e => e.BaseItems!.Any(w => w.ItemId == filter.ItemId && w.ListOrder <= filter.MaxListOrd
 383        }
 384
 0385        if (!string.IsNullOrWhiteSpace(filter.NameContains))
 386        {
 0387            var nameContainsUpper = filter.NameContains.ToUpper();
 0388            query = query.Where(e => e.Name.ToUpper().Contains(nameContainsUpper));
 389        }
 390
 0391        if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
 392        {
 0393            query = query.Where(e => e.Name.StartsWith(filter.NameStartsWith.ToLowerInvariant()));
 394        }
 395
 0396        if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
 397        {
 0398            query = query.Where(e => e.Name.CompareTo(filter.NameLessThan.ToLowerInvariant()) < 0);
 399        }
 400
 0401        if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
 402        {
 0403            query = query.Where(e => e.Name.CompareTo(filter.NameStartsWithOrGreater.ToLowerInvariant()) >= 0);
 404        }
 405
 0406        return query;
 407    }
 408
 409    private bool IsAlphaNumeric(string str)
 410    {
 0411        if (string.IsNullOrWhiteSpace(str))
 412        {
 0413            return false;
 414        }
 415
 0416        for (int i = 0; i < str.Length; i++)
 417        {
 0418            if (!char.IsLetter(str[i]) && !char.IsNumber(str[i]))
 419            {
 0420                return false;
 421            }
 422        }
 423
 0424        return true;
 425    }
 426
 427    private bool IsValidPersonType(string value)
 428    {
 0429        return IsAlphaNumeric(value);
 430    }
 431}