< 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
12%
Covered lines: 15
Uncovered lines: 103
Coverable lines: 118
Total lines: 267
Line coverage: 12.7%
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 5/16/2026 - 12:15:55 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: 26310/29/2026 - 12:17:11 AM Line coverage: 12.7% (15/118) Branch coverage: 0% (0/24) Total lines: 267 5/16/2026 - 12:15:55 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: 26310/29/2026 - 12:17:11 AM Line coverage: 12.7% (15/118) Branch coverage: 0% (0/24) Total lines: 267

Coverage delta

Coverage delta 8 -8

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 Emby.Server.Implementations.Library.Validators;
 8using Jellyfin.Data.Enums;
 9using Jellyfin.Database.Implementations;
 10using Jellyfin.Database.Implementations.Entities;
 11using MediaBrowser.Controller.Entities;
 12using MediaBrowser.Controller.Library;
 13using MediaBrowser.Controller.Persistence;
 14using MediaBrowser.Controller.Providers;
 15using MediaBrowser.Model.Globalization;
 16using MediaBrowser.Model.IO;
 17using MediaBrowser.Model.Tasks;
 18using Microsoft.EntityFrameworkCore;
 19using Microsoft.Extensions.Logging;
 20
 21namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
 22
 23/// <summary>
 24/// Class PeopleValidationTask.
 25/// </summary>
 26public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
 27{
 28    private readonly ILibraryManager _libraryManager;
 29    private readonly ILocalizationManager _localization;
 30    private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
 31    private readonly IFileSystem _fileSystem;
 32    private readonly ILogger<PeopleValidationTask> _logger;
 33    private readonly ILogger<PeopleValidator> _validatorLogger;
 34    private readonly IItemTypeLookup _itemTypeLookup;
 35
 36    /// <summary>
 37    /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
 38    /// </summary>
 39    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
 40    /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
 41    /// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
 42    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
 43    /// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param>
 44    /// <param name="validatorLogger">Instance of the <see cref="ILogger{PeopleValidator}"/> interface.</param>
 45    /// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param>
 46    public PeopleValidationTask(
 47        ILibraryManager libraryManager,
 48        ILocalizationManager localization,
 49        IDbContextFactory<JellyfinDbContext> dbContextFactory,
 50        IFileSystem fileSystem,
 51        ILogger<PeopleValidationTask> logger,
 52        ILogger<PeopleValidator> validatorLogger,
 53        IItemTypeLookup itemTypeLookup)
 54    {
 2755        _libraryManager = libraryManager;
 2756        _localization = localization;
 2757        _dbContextFactory = dbContextFactory;
 2758        _fileSystem = fileSystem;
 2759        _logger = logger;
 2760        _validatorLogger = validatorLogger;
 2761        _itemTypeLookup = itemTypeLookup;
 2762    }
 63
 64    /// <inheritdoc />
 2765    public string Name => _localization.GetLocalizedString("TaskRefreshPeople");
 66
 67    /// <inheritdoc />
 068    public string Description => _localization.GetLocalizedString("TaskRefreshPeopleDescription");
 69
 70    /// <inheritdoc />
 071    public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
 72
 73    /// <inheritdoc />
 074    public string Key => "RefreshPeople";
 75
 76    /// <inheritdoc />
 077    public bool IsHidden => false;
 78
 79    /// <inheritdoc />
 080    public bool IsEnabled => true;
 81
 82    /// <inheritdoc />
 083    public bool IsLogged => true;
 84
 85    /// <summary>
 86    /// Creates the triggers that define when the task will run.
 87    /// </summary>
 88    /// <returns>An <see cref="IEnumerable{TaskTriggerInfo}"/> containing the default trigger infos for this task.</retu
 89    public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
 90    {
 2791        yield return new TaskTriggerInfo
 2792        {
 2793            Type = TaskTriggerInfoType.IntervalTrigger,
 2794            IntervalTicks = TimeSpan.FromDays(7).Ticks
 2795        };
 2796    }
 97
 98    /// <inheritdoc />
 99    public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
 100    {
 101        // People validation performs heavy database writes that contend with an active library scan.
 102        // Defer it until the scan has finished; the task will run again on its next trigger.
 0103        if (_libraryManager.IsScanRunning)
 104        {
 0105            _logger.LogInformation("Skipping people validation because a library scan is currently running.");
 0106            return;
 107        }
 108
 109        // Phase 1: Deduplicate and remove orphaned people (0-33%)
 0110        var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0111        await using (context.ConfigureAwait(false))
 112        {
 0113            IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 3));
 0114            var dupQuery = context.Peoples
 0115                    .GroupBy(e => new { e.Name, e.PersonType })
 0116                    .Where(e => e.Count() > 1)
 0117                    .OrderBy(e => e.Key.Name)
 0118                    .ThenBy(e => e.Key.PersonType)
 0119                    .Select(e => e.Select(f => f.Id).ToArray());
 120
 0121            var total = dupQuery.Count();
 122
 123            const int PartitionSize = 100;
 0124            var iterator = 0;
 125            int itemCounter;
 0126            var buffer = ArrayPool<Guid[]>.Shared.Rent(PartitionSize)!;
 127            try
 128            {
 129                do
 130                {
 0131                    itemCounter = 0;
 0132                    await foreach (var item in dupQuery
 0133                        .Take(PartitionSize)
 0134                        .AsAsyncEnumerable()
 0135                        .WithCancellation(cancellationToken)
 0136                        .ConfigureAwait(false))
 137                    {
 0138                        buffer[itemCounter++] = item;
 139                    }
 140
 0141                    for (int i = 0; i < itemCounter; i++)
 142                    {
 0143                        var item = buffer[i];
 0144                        var reference = item[0];
 0145                        var dups = item[1..];
 0146                        await context.PeopleBaseItemMap.WhereOneOrMany(dups, e => e.PeopleId)
 0147                            .ExecuteUpdateAsync(e => e.SetProperty(f => f.PeopleId, reference), cancellationToken)
 0148                            .ConfigureAwait(false);
 0149                        await context.Peoples.Where(e => dups.Contains(e.Id)).ExecuteDeleteAsync(cancellationToken).Conf
 0150                        subProgress.Report(100f / total * ((iterator * PartitionSize) + i));
 0151                    }
 152
 0153                    iterator++;
 0154                } while (itemCounter == PartitionSize && !cancellationToken.IsCancellationRequested);
 0155            }
 156            finally
 157            {
 0158                ArrayPool<Guid[]>.Shared.Return(buffer);
 159            }
 160
 0161            var peopleToDelete = await context.Peoples
 0162                .Where(p => !context.PeopleBaseItemMap.Any(m => m.PeopleId.Equals(p.Id)))
 0163                .ExecuteDeleteAsync(cancellationToken)
 0164                .ConfigureAwait(false);
 0165            _logger.LogInformation("Removed {Count} orphaned people.", peopleToDelete);
 166
 0167            subProgress.Report(100);
 0168        }
 169
 170        // Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are
 171        // cleaned up above, so dead people are removed in a single pass instead of requiring a second run.
 0172        IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33));
 0173        await new PeopleValidator(_libraryManager, _validatorLogger)
 0174            .Run(validateProgress, cancellationToken)
 0175            .ConfigureAwait(false);
 176
 177        // Phase 3: Refresh images for people missing them (66-100%)
 0178        IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66));
 0179        await RefreshPeopleImagesAsync(refreshProgress, cancellationToken).ConfigureAwait(false);
 180
 0181        progress.Report(100);
 0182    }
 183
 184    private async Task RefreshPeopleImagesAsync(IProgress<double> progress, CancellationToken cancellationToken)
 185    {
 0186        var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
 0187        var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
 188
 189        List<Guid> peopleIds;
 190
 0191        var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0192        await using (context.ConfigureAwait(false))
 193        {
 194            // Read the candidates in one go rather than paging them. A refresh stamps the person and takes
 195            // it out of this set, so a growing offset over a shrinking set walks past people it never visits.
 0196            peopleIds = await context.BaseItems
 0197                .AsNoTracking()
 0198                .Where(b => b.Type == personTypeName)
 0199                .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
 0200                .Where(b =>
 0201                    !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
 0202                    string.IsNullOrEmpty(b.Overview))
 0203                .OrderBy(b => b.Id)
 0204                .Select(b => b.Id)
 0205                .ToListAsync(cancellationToken)
 0206                .ConfigureAwait(false);
 207        }
 208
 0209        _logger.LogDebug("Found {Count} people needing image/overview refresh", peopleIds.Count);
 210
 0211        if (peopleIds.Count == 0)
 212        {
 0213            progress.Report(100);
 0214            return;
 215        }
 216
 0217        var numComplete = 0;
 0218        var numRefreshed = 0;
 219
 0220        foreach (var personId in peopleIds)
 221        {
 0222            cancellationToken.ThrowIfCancellationRequested();
 223
 0224            if (await RefreshPersonAsync(personId, cancellationToken).ConfigureAwait(false))
 225            {
 0226                numRefreshed++;
 227            }
 228
 0229            numComplete++;
 0230            progress.Report(100.0 * numComplete / peopleIds.Count);
 231        }
 232
 0233        _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
 0234    }
 235
 236    private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken)
 237    {
 238        try
 239        {
 0240            if (_libraryManager.GetItemById(personId) is not Person item)
 241            {
 0242                return false;
 243            }
 244
 0245            var hasImage = item.HasImage(MediaBrowser.Model.Entities.ImageType.Primary);
 0246            var hasOverview = !string.IsNullOrEmpty(item.Overview);
 247
 0248            var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
 0249            {
 0250                ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh,
 0251                MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh
 0252            };
 253
 0254            await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
 0255            return true;
 256        }
 0257        catch (OperationCanceledException)
 258        {
 0259            throw;
 260        }
 0261        catch (Exception ex)
 262        {
 0263            _logger.LogError(ex, "Error refreshing images for person {PersonId}", personId);
 0264            return false;
 265        }
 0266    }
 267}