Delete a credit once no item maps to it any more

This commit is contained in:
Shadowghost
2026-08-25 20:23:14 +02:00
parent 8c0775e941
commit 6978dfc294
6 changed files with 132 additions and 1 deletions
@@ -3558,6 +3558,12 @@ namespace Emby.Server.Implementations.Library
return _peopleRepository.GetPeopleNames(query);
}
/// <inheritdoc/>
public int DeleteOrphanedCredits()
{
return _peopleRepository.DeleteOrphanedCredits();
}
/// <inheritdoc/>
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
{
@@ -49,6 +49,14 @@ public class PeopleValidator
/// <returns>Task.</returns>
public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
{
// Before the refresh below walks them: a credit no item maps to any more stands for nothing,
// and while it is there the person it names cannot reach the dead-person sweep either.
var numOrphaned = _libraryManager.DeleteOrphanedCredits();
if (numOrphaned > 0)
{
_logger.LogDebug("Deleted {Amount} credits no item maps to", numOrphaned);
}
var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
var numComplete = 0;
@@ -115,6 +123,6 @@ public class PeopleValidator
progress.Report(100);
_logger.LogInformation("People validation complete");
_logger.LogInformation("People validation complete, deleted {Orphaned} orphaned credits", numOrphaned);
}
}
@@ -194,12 +194,44 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
listOrder++;
}
var droppedCredits = existingMaps.Select(e => e.PeopleId).Distinct().ToArray();
context.PeopleBaseItemMap.RemoveRange(existingMaps);
context.SaveChanges();
// Nothing else ever deletes a credit row, so one left without a single mapping outlives the
// credit it stood for: it keeps a person of that name off the dead-person sweep, which only
// sees items no credit names, and keeps the name in every by-name list. That is how a credit
// a provider dropped, or one a broken provider result invented, becomes impossible to clean up.
DeleteCreditsWithoutMapping(context, droppedCredits);
context.SaveChanges();
transaction.Commit();
}
/// <inheritdoc/>
public int DeleteOrphanedCredits()
{
using var context = _dbProvider.CreateDbContext();
return DeleteCreditsWithoutMapping(context, null);
}
// A null candidate list sweeps every credit, anything else only the ones just unmapped.
private int DeleteCreditsWithoutMapping(JellyfinDbContext context, IReadOnlyList<Guid>? candidates)
{
if (candidates is not null && candidates.Count == 0)
{
return 0;
}
var credits = candidates is null
? context.Peoples.AsQueryable()
: context.Peoples.WhereOneOrMany(candidates, e => e.Id);
return credits.Where(e => !context.PeopleBaseItemMap.Any(f => f.PeopleId == e.Id)).ExecuteDelete();
}
/// <inheritdoc/>
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
{
@@ -605,6 +605,12 @@ namespace MediaBrowser.Controller.Library
/// <returns>List&lt;System.String&gt;.</returns>
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query);
/// <summary>
/// Deletes every credit that no item maps to any more.
/// </summary>
/// <returns>The number of credits that were deleted.</returns>
int DeleteOrphanedCredits();
/// <summary>
/// Gets the distinct people names per item for multiple items.
/// </summary>
@@ -33,6 +33,12 @@ public interface IPeopleRepository
/// <returns>The list of people names matching the filter.</returns>
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter);
/// <summary>
/// Deletes every credit that no item maps to any more.
/// </summary>
/// <returns>The number of credits that were deleted.</returns>
int DeleteOrphanedCredits();
/// <summary>
/// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table.
/// </summary>
@@ -142,6 +142,79 @@ public sealed class PeopleRepositoryUpdatePeopleTests : SqliteDbTestFixture
Assert.Equal("Hero", map.Role);
}
[Fact]
public void UpdatePeople_CreditDroppedByTheProvider_LeavesNoCreditRowBehind()
{
_repository.UpdatePeople(_itemId, [
CreatePerson("Person A", PersonKind.Actor, "Hero"),
CreatePerson("Person B", PersonKind.Actor, "Villain")
]);
_repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
using var ctx = CreateDbContext();
Assert.Equal(["Person A"], ctx.Peoples.Select(e => e.Name).ToArray());
}
[Fact]
public void UpdatePeople_CreditStillHeldByAnotherItem_IsKept()
{
_repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
_repository.UpdatePeople(AddMovie("Other Movie"), [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
_repository.UpdatePeople(_itemId, []);
using var after = CreateDbContext();
Assert.Single(after.Peoples);
Assert.Single(after.PeopleBaseItemMap);
}
[Fact]
public void DeleteOrphanedCredits_CreditNoItemMapsTo_IsDeleted()
{
_repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
using (var ctx = CreateDbContext())
{
// The state a credit was left in before UpdatePeople cleaned up after itself.
ctx.PeopleBaseItemMap.RemoveRange(ctx.PeopleBaseItemMap);
ctx.SaveChanges();
}
Assert.Equal(1, _repository.DeleteOrphanedCredits());
using var after = CreateDbContext();
Assert.Empty(after.Peoples);
}
[Fact]
public void DeleteOrphanedCredits_CreditAnItemMapsTo_IsKept()
{
_repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
Assert.Equal(0, _repository.DeleteOrphanedCredits());
using var after = CreateDbContext();
Assert.Single(after.Peoples);
}
private Guid AddMovie(string name)
{
var id = Guid.NewGuid();
using var ctx = CreateDbContext();
ctx.BaseItems.Add(new BaseItemEntity
{
Id = id,
Type = new ItemTypeLookup().BaseItemKindNames[BaseItemKind.Movie],
Name = name,
MediaType = "Video",
IsMovie = true,
IsFolder = false,
IsVirtualItem = false
});
ctx.SaveChanges();
return id;
}
private static PersonInfo CreatePerson(string name, PersonKind type, string role)
{
return new PersonInfo