fix: Handle unknown item types gracefully in DeserializeBaseItem

When querying items with recursive=true, items with types from removed
plugins would cause a 500 error. Now these items are skipped with a
warning log instead of throwing an exception.

Fixes #15945
This commit is contained in:
ZeusCraft10
2026-01-05 21:08:26 -05:00
parent a1e0e4fd9d
commit 0ff869dfcd
3 changed files with 100 additions and 19 deletions

View File

@@ -277,7 +277,7 @@ public sealed class BaseItemRepository
dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter); dbQuery = ApplyNavigations(dbQuery, filter);
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
result.StartIndex = filter.StartIndex ?? 0; result.StartIndex = filter.StartIndex ?? 0;
return result; return result;
} }
@@ -297,7 +297,7 @@ public sealed class BaseItemRepository
dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter); dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -341,7 +341,7 @@ public sealed class BaseItemRepository
mainquery = ApplyNavigations(mainquery, filter); mainquery = ApplyNavigations(mainquery, filter);
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -1159,7 +1159,7 @@ public sealed class BaseItemRepository
return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null; return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null;
} }
private BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false) private BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
{ {
ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity)); ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity));
if (_serverConfigurationManager?.Configuration is null) if (_serverConfigurationManager?.Configuration is null)
@@ -1182,11 +1182,19 @@ public sealed class BaseItemRepository
/// <param name="logger">Logger.</param> /// <param name="logger">Logger.</param>
/// <param name="appHost">The application server Host.</param> /// <param name="appHost">The application server Host.</param>
/// <param name="skipDeserialization">If only mapping should be processed.</param> /// <param name="skipDeserialization">If only mapping should be processed.</param>
/// <returns>A mapped BaseItem.</returns> /// <returns>A mapped BaseItem, or null if the item type is unknown.</returns>
/// <exception cref="InvalidOperationException">Will be thrown if an invalid serialisation is requested.</exception> public static BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
public static BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
{ {
var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialize unknown type."); var type = GetType(baseItemEntity.Type);
if (type is null)
{
logger.LogWarning(
"Skipping item {ItemId} with unknown type '{ItemType}'. This may indicate a removed plugin or database corruption.",
baseItemEntity.Id,
baseItemEntity.Type);
return null;
}
BaseItemDto? dto = null; BaseItemDto? dto = null;
if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization) if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization)
{ {
@@ -1353,10 +1361,9 @@ public sealed class BaseItemRepository
.. resultQuery .. resultQuery
.AsEnumerable() .AsEnumerable()
.Where(e => e is not null) .Where(e => e is not null)
.Select(e => .Select(e => (Item: DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount))
{ .Where(e => e.Item is not null)
return (DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount); .Select(e => (e.Item!, e.itemCount))
})
]; ];
} }
else else
@@ -1367,10 +1374,9 @@ public sealed class BaseItemRepository
.. query .. query
.AsEnumerable() .AsEnumerable()
.Where(e => e is not null) .Where(e => e is not null)
.Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e => .Select(e => (Item: DeserializeBaseItem(e, filter.SkipDeserialization), ItemCounts: (ItemCounts?)null))
{ .Where(e => e.Item is not null)
return (DeserializeBaseItem(e, filter.SkipDeserialization), null); .Select(e => (e.Item!, e.ItemCounts))
})
]; ];
} }
@@ -2671,6 +2677,6 @@ public sealed class BaseItemRepository
.Where(e => artistNames.Contains(e.Name)) .Where(e => artistNames.Contains(e.Name))
.ToArray(); .ToArray();
return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray()); return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
} }
} }

View File

@@ -1247,8 +1247,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
} }
var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false); var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false);
if (baseItem is not null)
{
var dataKeys = baseItem.GetUserDataKeys(); var dataKeys = baseItem.GetUserDataKeys();
userDataKeys.AddRange(dataKeys); userDataKeys.AddRange(dataKeys);
}
return (entity, userDataKeys.ToArray()); return (entity, userDataKeys.ToArray());
} }

View File

@@ -0,0 +1,72 @@
using System;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Item;
public class BaseItemRepositoryTests
{
[Fact]
public void DeserializeBaseItem_WithUnknownType_ReturnsNull()
{
// Arrange
var entity = new BaseItemEntity
{
Id = Guid.NewGuid(),
Type = "NonExistent.Plugin.CustomItemType"
};
// Act
var result = BaseItemRepository.DeserializeBaseItem(entity, NullLogger.Instance, null, false);
// Assert
Assert.Null(result);
}
[Fact]
public void DeserializeBaseItem_WithUnknownType_LogsWarning()
{
// Arrange
var entity = new BaseItemEntity
{
Id = Guid.NewGuid(),
Type = "NonExistent.Plugin.CustomItemType"
};
var loggerMock = new Mock<ILogger>();
// Act
BaseItemRepository.DeserializeBaseItem(entity, loggerMock.Object, null, false);
// Assert
loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("unknown type", StringComparison.OrdinalIgnoreCase)),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public void DeserializeBaseItem_WithKnownType_ReturnsItem()
{
// Arrange
var entity = new BaseItemEntity
{
Id = Guid.NewGuid(),
Type = "MediaBrowser.Controller.Entities.Movies.Movie"
};
// Act
var result = BaseItemRepository.DeserializeBaseItem(entity, NullLogger.Instance, null, false);
// Assert
Assert.NotNull(result);
}
}