From dc92e3b0e489b7d6efe9ba950cbf124b84a3a4ce Mon Sep 17 00:00:00 2001 From: John Corser Date: Sun, 19 Apr 2026 13:33:29 -0400 Subject: Fix actor images not displayed until clicked Move image refresh logic from PeopleValidator (which runs during library scans) into PeopleValidationTask (the "Refresh People" scheduled task). This keeps library scans fast while ensuring the scheduled task fetches missing images from remote providers like TMDB. People missing a Primary image or overview get refreshed with MetadataRefreshMode.Default instead of ValidationOnly, with a 30-day cooldown to avoid hammering providers for people they have no data for. Fixes jellyfin#8103 --- .../ScheduledTasks/Tasks/PeopleValidationTask.cs | 79 +++++++++++++++++++++- 1 file changed, 76 insertions(+), 3 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index 3451c458f9..0c1d004aed 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -5,8 +5,12 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -21,6 +25,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask private readonly ILibraryManager _libraryManager; private readonly ILocalizationManager _localization; private readonly IDbContextFactory _dbContextFactory; + private readonly IFileSystem _fileSystem; private readonly ILogger _logger; /// @@ -29,12 +34,19 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. - public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory dbContextFactory, ILogger logger) + public PeopleValidationTask( + ILibraryManager libraryManager, + ILocalizationManager localization, + IDbContextFactory dbContextFactory, + IFileSystem fileSystem, + ILogger logger) { _libraryManager = libraryManager; _localization = localization; _dbContextFactory = dbContextFactory; + _fileSystem = fileSystem; _logger = logger; } @@ -83,10 +95,11 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask return; } + // Phase 1: Deduplicate and remove orphaned people (0-33%) var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - IProgress subProgress = new Progress((val) => progress.Report(val / 2)); + IProgress subProgress = new Progress((val) => progress.Report(val / 3)); var dupQuery = context.Peoples .GroupBy(e => new { e.Name, e.PersonType }) .Where(e => e.Count() > 1) @@ -141,9 +154,69 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask subProgress.Report(100); } - IProgress validateProgress = new Progress((val) => progress.Report((val / 2) + 50)); + // Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are + // cleaned up above, so dead people are removed in a single pass instead of requiring a second run. + IProgress validateProgress = new Progress((val) => progress.Report((val / 3) + 33)); await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false); + // Phase 3: Refresh images for people missing them (66-100%) + IProgress refreshProgress = new Progress((val) => progress.Report((val / 3) + 66)); + await RefreshPeopleImagesAsync(refreshProgress, cancellationToken).ConfigureAwait(false); + progress.Report(100); } + + private async Task RefreshPeopleImagesAsync(IProgress progress, CancellationToken cancellationToken) + { + var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery()); + var numPeople = people.Count; + var numComplete = 0; + var numRefreshed = 0; + + _logger.LogDebug("Checking {Count} people for missing images", numPeople); + + foreach (var person in people) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var item = _libraryManager.GetPerson(person); + if (item is null) + { + continue; + } + + var hasImage = item.HasImage(ImageType.Primary, 0); + var hasOverview = !string.IsNullOrWhiteSpace(item.Overview); + + if ((hasImage && hasOverview) || (DateTime.UtcNow - item.DateLastRefreshed).TotalDays < 30) + { + continue; + } + + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default, + MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default + }; + + await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); + numRefreshed++; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing images for {Person}", person); + } + + numComplete++; + progress.Report(100.0 * numComplete / numPeople); + } + + _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); + } } -- cgit v1.2.3 From d55f80842391e1b69db927ae8938c0455ddb5192 Mon Sep 17 00:00:00 2001 From: John Corser Date: Sat, 9 May 2026 14:11:16 -0400 Subject: Move people filtering to database query Instead of loading all people names and checking each one in memory, query the database directly for Person items that need refresh: - Missing primary image OR missing overview - Not refreshed within the last 30 days This reduces the operation from N+1 queries (1 for all names + 1 per person to load) to a single filtered query returning only the IDs that need work. --- .../ScheduledTasks/Tasks/PeopleValidationTask.cs | 84 ++++++++++++---------- 1 file changed, 48 insertions(+), 36 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index 0c1d004aed..7d42693b39 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; @@ -168,55 +169,66 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask private async Task RefreshPeopleImagesAsync(IProgress progress, CancellationToken cancellationToken) { - var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery()); - var numPeople = people.Count; - var numComplete = 0; - var numRefreshed = 0; + var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); + var personTypeName = typeof(Person).FullName!; - _logger.LogDebug("Checking {Count} people for missing images", numPeople); - - foreach (var person in people) + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) { - cancellationToken.ThrowIfCancellationRequested(); + var peopleIds = await context.BaseItems + .AsNoTracking() + .Where(b => b.Type == personTypeName) + .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) + .Where(b => + !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || + string.IsNullOrEmpty(b.Overview)) + .Select(b => b.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); - try + var numPeople = peopleIds.Count; + var numComplete = 0; + var numRefreshed = 0; + + _logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople); + + foreach (var personId in peopleIds) { - var item = _libraryManager.GetPerson(person); - if (item is null) + cancellationToken.ThrowIfCancellationRequested(); + + try { - continue; - } + if (_libraryManager.GetItemById(personId) is not Person item) + { + continue; + } - var hasImage = item.HasImage(ImageType.Primary, 0); - var hasOverview = !string.IsNullOrWhiteSpace(item.Overview); + var hasImage = item.HasImage(ImageType.Primary, 0); + var hasOverview = !string.IsNullOrWhiteSpace(item.Overview); - if ((hasImage && hasOverview) || (DateTime.UtcNow - item.DateLastRefreshed).TotalDays < 30) + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default, + MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default + }; + + await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); + numRefreshed++; + } + catch (OperationCanceledException) { - continue; + throw; } - - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + catch (Exception ex) { - ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default, - MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default - }; + _logger.LogError(ex, "Error refreshing images for person {PersonId}", personId); + } - await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); - numRefreshed++; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error refreshing images for {Person}", person); + numComplete++; + progress.Report(100.0 * numComplete / numPeople); } - numComplete++; - progress.Report(100.0 * numComplete / numPeople); + _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); } - - _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); } } -- cgit v1.2.3 From a888257c8299026fb73c4c90160c71cdacc0ee37 Mon Sep 17 00:00:00 2001 From: John Corser Date: Sat, 9 May 2026 14:38:22 -0400 Subject: Project hasImage/hasOverview from DB query Instead of re-checking image/overview on the domain object after loading, project the values directly from the database query as part of the anonymous type selection. This avoids redundant checks since the DB already has this information. --- .../ScheduledTasks/Tasks/PeopleValidationTask.cs | 25 +++++++++++----------- 1 file changed, 13 insertions(+), 12 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index 7d42693b39..bdda7937fd 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -9,7 +9,6 @@ using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; @@ -175,41 +174,43 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - var peopleIds = await context.BaseItems + var people = await context.BaseItems .AsNoTracking() .Where(b => b.Type == personTypeName) .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) .Where(b => !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || string.IsNullOrEmpty(b.Overview)) - .Select(b => b.Id) + .Select(b => new + { + b.Id, + HasImage = b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary), + HasOverview = !string.IsNullOrEmpty(b.Overview) + }) .ToListAsync(cancellationToken) .ConfigureAwait(false); - var numPeople = peopleIds.Count; + var numPeople = people.Count; var numComplete = 0; var numRefreshed = 0; _logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople); - foreach (var personId in peopleIds) + foreach (var entry in people) { cancellationToken.ThrowIfCancellationRequested(); try { - if (_libraryManager.GetItemById(personId) is not Person item) + if (_libraryManager.GetItemById(entry.Id) is not Person item) { continue; } - var hasImage = item.HasImage(ImageType.Primary, 0); - var hasOverview = !string.IsNullOrWhiteSpace(item.Overview); - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { - ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default, - MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default + ImageRefreshMode = entry.HasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default, + MetadataRefreshMode = entry.HasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default }; await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); @@ -221,7 +222,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask } catch (Exception ex) { - _logger.LogError(ex, "Error refreshing images for person {PersonId}", personId); + _logger.LogError(ex, "Error refreshing images for person {PersonId}", entry.Id); } numComplete++; -- cgit v1.2.3 From ef6f342a54c9c6c8713b650c4ad60005af600ade Mon Sep 17 00:00:00 2001 From: John Corser Date: Sun, 17 May 2026 10:28:40 -0400 Subject: Use IItemTypeLookup and QueryPartitionHelpers Address review feedback: - Replace typeof(Person).FullName with IItemTypeLookup.BaseItemKindNames - Replace foreach+ToListAsync with PartitionEagerAsync for batched iteration with built-in progress reporting - Check HasImage/HasOverview on the loaded domain Person object instead of projecting from the DB query --- .../ScheduledTasks/Tasks/PeopleValidationTask.cs | 102 +++++++++++++-------- 1 file changed, 65 insertions(+), 37 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index bdda7937fd..dff9a473af 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -4,10 +4,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; @@ -27,6 +29,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask private readonly IDbContextFactory _dbContextFactory; private readonly IFileSystem _fileSystem; private readonly ILogger _logger; + private readonly IItemTypeLookup _itemTypeLookup; /// /// Initializes a new instance of the class. @@ -36,18 +39,21 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public PeopleValidationTask( ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory dbContextFactory, IFileSystem fileSystem, - ILogger logger) + ILogger logger, + IItemTypeLookup itemTypeLookup) { _libraryManager = libraryManager; _localization = localization; _dbContextFactory = dbContextFactory; _fileSystem = fileSystem; _logger = logger; + _itemTypeLookup = itemTypeLookup; } /// @@ -169,60 +175,50 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask private async Task RefreshPeopleImagesAsync(IProgress progress, CancellationToken cancellationToken) { var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); - var personTypeName = typeof(Person).FullName!; + var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - var people = await context.BaseItems + const int PartitionSize = 100; + + var numPeople = await context.BaseItems .AsNoTracking() .Where(b => b.Type == personTypeName) .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) .Where(b => !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || string.IsNullOrEmpty(b.Overview)) - .Select(b => new - { - b.Id, - HasImage = b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary), - HasOverview = !string.IsNullOrEmpty(b.Overview) - }) - .ToListAsync(cancellationToken) + .CountAsync(cancellationToken) .ConfigureAwait(false); - var numPeople = people.Count; - var numComplete = 0; - var numRefreshed = 0; - _logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople); - foreach (var entry in people) + if (numPeople == 0) { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - if (_libraryManager.GetItemById(entry.Id) is not Person item) - { - continue; - } + progress.Report(100); + return; + } - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - ImageRefreshMode = entry.HasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default, - MetadataRefreshMode = entry.HasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default - }; + var numComplete = 0; + var numRefreshed = 0; - await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); - numRefreshed++; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) + await foreach (var entry in context.BaseItems + .AsNoTracking() + .Where(b => b.Type == personTypeName) + .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) + .Where(b => + !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || + string.IsNullOrEmpty(b.Overview)) + .OrderBy(b => b.Id) + .WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition)) + .PartitionEagerAsync(PartitionSize, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false)) { - _logger.LogError(ex, "Error refreshing images for person {PersonId}", entry.Id); + numRefreshed++; } numComplete++; @@ -232,4 +228,36 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); } } + + private async Task RefreshPersonAsync(Guid personId, CancellationToken cancellationToken) + { + try + { + if (_libraryManager.GetItemById(personId) is not Person item) + { + return false; + } + + var hasImage = item.HasImage(MediaBrowser.Model.Entities.ImageType.Primary); + var hasOverview = !string.IsNullOrEmpty(item.Overview); + + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default, + MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default + }; + + await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing images for person {PersonId}", personId); + return false; + } + } } -- cgit v1.2.3