< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.ScheduledTasks.Tasks.PeopleValidationTask
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
Line coverage
11%
Covered lines: 14
Uncovered lines: 107
Coverable lines: 121
Total lines: 263
Line coverage: 11.5%
Branch coverage
0%
Covered branches: 0
Total branches: 24
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 4/9/2026 - 12:14:15 AM Line coverage: 45.4% (5/11) Total lines: 1294/19/2026 - 12:14:27 AM Line coverage: 20.7% (11/53) Branch coverage: 0% (0/10) Total lines: 1296/28/2026 - 12:15:35 AM Line coverage: 19% (12/63) Branch coverage: 0% (0/12) Total lines: 1497/6/2026 - 12:16:28 AM Line coverage: 11.5% (14/121) Branch coverage: 0% (0/24) Total lines: 263 4/19/2026 - 12:14:27 AM Line coverage: 20.7% (11/53) Branch coverage: 0% (0/10) Total lines: 1296/28/2026 - 12:15:35 AM Line coverage: 19% (12/63) Branch coverage: 0% (0/12) Total lines: 1497/6/2026 - 12:16:28 AM Line coverage: 11.5% (14/121) Branch coverage: 0% (0/24) Total lines: 263

Coverage delta

Coverage delta 25 -25

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Name()100%11100%
get_Description()100%210%
get_Category()100%210%
get_Key()100%210%
get_IsHidden()100%210%
get_IsEnabled()100%210%
get_IsLogged()100%210%
GetDefaultTriggers()100%11100%
ExecuteAsync()0%156120%
RefreshPeopleImagesAsync()0%4260%
RefreshPersonAsync()0%4260%

File(s)

/srv/git/jellyfin/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs

#LineLine coverage
 1using System;
 2using System.Buffers;
 3using System.Collections.Generic;
 4using System.Linq;
 5using System.Threading;
 6using System.Threading.Tasks;
 7using Jellyfin.Data.Enums;
 8using Jellyfin.Database.Implementations;
 9using Jellyfin.Database.Implementations.Entities;
 10using MediaBrowser.Controller.Entities;
 11using MediaBrowser.Controller.Library;
 12using MediaBrowser.Controller.Persistence;
 13using MediaBrowser.Controller.Providers;
 14using MediaBrowser.Model.Globalization;
 15using MediaBrowser.Model.IO;
 16using MediaBrowser.Model.Tasks;
 17using Microsoft.EntityFrameworkCore;
 18using Microsoft.Extensions.Logging;
 19
 20namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
 21
 22/// <summary>
 23/// Class PeopleValidationTask.
 24/// </summary>
 25public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
 26{
 27    private readonly ILibraryManager _libraryManager;
 28    private readonly ILocalizationManager _localization;
 29    private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
 30    private readonly IFileSystem _fileSystem;
 31    private readonly ILogger<PeopleValidationTask> _logger;
 32    private readonly IItemTypeLookup _itemTypeLookup;
 33
 34    /// <summary>
 35    /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
 36    /// </summary>
 37    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
 38    /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
 39    /// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
 40    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
 41    /// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param>
 42    /// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param>
 43    public PeopleValidationTask(
 44        ILibraryManager libraryManager,
 45        ILocalizationManager localization,
 46        IDbContextFactory<JellyfinDbContext> dbContextFactory,
 47        IFileSystem fileSystem,
 48        ILogger<PeopleValidationTask> logger,
 49        IItemTypeLookup itemTypeLookup)
 50    {
 2251        _libraryManager = libraryManager;
 2252        _localization = localization;
 2253        _dbContextFactory = dbContextFactory;
 2254        _fileSystem = fileSystem;
 2255        _logger = logger;
 2256        _itemTypeLookup = itemTypeLookup;
 2257    }
 58
 59    /// <inheritdoc />
 2260    public string Name => _localization.GetLocalizedString("TaskRefreshPeople");
 61
 62    /// <inheritdoc />
 063    public string Description => _localization.GetLocalizedString("TaskRefreshPeopleDescription");
 64
 65    /// <inheritdoc />
 066    public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
 67
 68    /// <inheritdoc />
 069    public string Key => "RefreshPeople";
 70
 71    /// <inheritdoc />
 072    public bool IsHidden => false;
 73
 74    /// <inheritdoc />
 075    public bool IsEnabled => true;
 76
 77    /// <inheritdoc />
 078    public bool IsLogged => true;
 79
 80    /// <summary>
 81    /// Creates the triggers that define when the task will run.
 82    /// </summary>
 83    /// <returns>An <see cref="IEnumerable{TaskTriggerInfo}"/> containing the default trigger infos for this task.</retu
 84    public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
 85    {
 2286        yield return new TaskTriggerInfo
 2287        {
 2288            Type = TaskTriggerInfoType.IntervalTrigger,
 2289            IntervalTicks = TimeSpan.FromDays(7).Ticks
 2290        };
 2291    }
 92
 93    /// <inheritdoc />
 94    public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
 95    {
 96        // People validation performs heavy database writes that contend with an active library scan.
 97        // Defer it until the scan has finished; the task will run again on its next trigger.
 098        if (_libraryManager.IsScanRunning)
 99        {
 0100            _logger.LogInformation("Skipping people validation because a library scan is currently running.");
 0101            return;
 102        }
 103
 104        // Phase 1: Deduplicate and remove orphaned people (0-33%)
 0105        var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0106        await using (context.ConfigureAwait(false))
 107        {
 0108            IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 3));
 0109            var dupQuery = context.Peoples
 0110                    .GroupBy(e => new { e.Name, e.PersonType })
 0111                    .Where(e => e.Count() > 1)
 0112                    .Select(e => e.Select(f => f.Id).ToArray());
 113
 0114            var total = dupQuery.Count();
 115
 116            const int PartitionSize = 100;
 0117            var iterator = 0;
 118            int itemCounter;
 0119            var buffer = ArrayPool<Guid[]>.Shared.Rent(PartitionSize)!;
 120            try
 121            {
 122                do
 123                {
 0124                    itemCounter = 0;
 0125                    await foreach (var item in dupQuery
 0126                        .Take(PartitionSize)
 0127                        .AsAsyncEnumerable()
 0128                        .WithCancellation(cancellationToken)
 0129                        .ConfigureAwait(false))
 130                    {
 0131                        buffer[itemCounter++] = item;
 132                    }
 133
 0134                    for (int i = 0; i < itemCounter; i++)
 135                    {
 0136                        var item = buffer[i];
 0137                        var reference = item[0];
 0138                        var dups = item[1..];
 0139                        await context.PeopleBaseItemMap.WhereOneOrMany(dups, e => e.PeopleId)
 0140                            .ExecuteUpdateAsync(e => e.SetProperty(f => f.PeopleId, reference), cancellationToken)
 0141                            .ConfigureAwait(false);
 0142                        await context.Peoples.Where(e => dups.Contains(e.Id)).ExecuteDeleteAsync(cancellationToken).Conf
 0143                        subProgress.Report(100f / total * ((iterator * PartitionSize) + i));
 0144                    }
 145
 0146                    iterator++;
 0147                } while (itemCounter == PartitionSize && !cancellationToken.IsCancellationRequested);
 0148            }
 149            finally
 150            {
 0151                ArrayPool<Guid[]>.Shared.Return(buffer);
 152            }
 153
 0154            var peopleToDelete = await context.Peoples
 0155                .Where(p => !context.PeopleBaseItemMap.Any(m => m.PeopleId.Equals(p.Id)))
 0156                .ExecuteDeleteAsync(cancellationToken)
 0157                .ConfigureAwait(false);
 0158            _logger.LogInformation("Removed {Count} orphaned people.", peopleToDelete);
 159
 0160            subProgress.Report(100);
 0161        }
 162
 163        // Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are
 164        // cleaned up above, so dead people are removed in a single pass instead of requiring a second run.
 0165        IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33));
 0166        await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false);
 167
 168        // Phase 3: Refresh images for people missing them (66-100%)
 0169        IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66));
 0170        await RefreshPeopleImagesAsync(refreshProgress, cancellationToken).ConfigureAwait(false);
 171
 0172        progress.Report(100);
 0173    }
 174
 175    private async Task RefreshPeopleImagesAsync(IProgress<double> progress, CancellationToken cancellationToken)
 176    {
 0177        var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
 0178        var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
 179
 0180        var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0181        await using (context.ConfigureAwait(false))
 182        {
 183            const int PartitionSize = 100;
 184
 0185            var numPeople = await context.BaseItems
 0186                .AsNoTracking()
 0187                .Where(b => b.Type == personTypeName)
 0188                .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
 0189                .Where(b =>
 0190                    !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
 0191                    string.IsNullOrEmpty(b.Overview))
 0192                .CountAsync(cancellationToken)
 0193                .ConfigureAwait(false);
 194
 0195            _logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople);
 196
 0197            if (numPeople == 0)
 198            {
 0199                progress.Report(100);
 0200                return;
 201            }
 202
 0203            var numComplete = 0;
 0204            var numRefreshed = 0;
 205
 0206            await foreach (var entry in context.BaseItems
 0207                .AsNoTracking()
 0208                .Where(b => b.Type == personTypeName)
 0209                .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
 0210                .Where(b =>
 0211                    !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
 0212                    string.IsNullOrEmpty(b.Overview))
 0213                .OrderBy(b => b.Id)
 0214                .WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partitio
 0215                .PartitionEagerAsync(PartitionSize, cancellationToken)
 0216                .WithCancellation(cancellationToken)
 0217                .ConfigureAwait(false))
 218            {
 0219                if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false))
 220                {
 0221                    numRefreshed++;
 222                }
 223
 0224                numComplete++;
 0225                progress.Report(100.0 * numComplete / numPeople);
 226            }
 227
 0228            _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
 229        }
 0230    }
 231
 232    private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken)
 233    {
 234        try
 235        {
 0236            if (_libraryManager.GetItemById(personId) is not Person item)
 237            {
 0238                return false;
 239            }
 240
 0241            var hasImage = item.HasImage(MediaBrowser.Model.Entities.ImageType.Primary);
 0242            var hasOverview = !string.IsNullOrEmpty(item.Overview);
 243
 0244            var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
 0245            {
 0246                ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default,
 0247                MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default
 0248            };
 249
 0250            await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
 0251            return true;
 252        }
 0253        catch (OperationCanceledException)
 254        {
 0255            throw;
 256        }
 0257        catch (Exception ex)
 258        {
 0259            _logger.LogError(ex, "Error refreshing images for person {PersonId}", personId);
 0260            return false;
 261        }
 0262    }
 263}