fix(db): materialise the rating list before updating #6

Merged
benvin merged 1 commits from benvin/fix-rating-levels-pg into main 2026-09-12 22:21:31 +10:00
2 changed files with 100 additions and 1 deletions
@@ -36,7 +36,9 @@ internal class MigrateRatingLevels : IDatabaseMigrationRoutine
_logger.LogInformation("Recalculating parental rating levels based on rating string.");
using var context = _provider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct();
// Read the whole list up front: the updates below run on the same connection, and a provider
// that cannot multiplex commands rejects them while the reader is still open.
var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct().ToList();
foreach (var rating in ratings)
{
if (string.IsNullOrEmpty(rating))
@@ -14,9 +14,12 @@ using Jellyfin.Server.Migrations.Routines;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Npgsql;
@@ -43,6 +46,9 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
private const string DanglingOwnerItemId = "44444444-4444-4444-4444-444444444444";
private const string MalformedOwnerItemId = "55555555-5555-5555-5555-555555555555";
private const string PlaceholderOwnedItemId = "66666666-6666-6666-6666-666666666666";
private const string UnratedItemId = "77777777-7777-7777-7777-777777777777";
private const string EmptyRatingItemId = "88888888-8888-8888-8888-888888888888";
private const string RatedItemId = "99999999-9999-9999-9999-999999999999";
private const string AliceId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
private const string BobId = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
@@ -248,6 +254,39 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
Assert.Equal(1L, await ScalarAsync<long>(dataSource, $"""SELECT count(*) FROM "BaseItems" WHERE "Id" = '{PlaceholderOwnedItemId}'""", cancellationToken));
}
[Fact]
public async Task MigrateRatingLevels_RecalculatesEveryRating()
{
var cancellationToken = TestContext.Current.CancellationToken;
var connectionString = await CreateDatabaseAsync("rating_levels", cancellationToken);
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
await MigrateAsync(dataSource, null, cancellationToken);
// A NULL and an empty rating are what make the migration issue an update while it is still
// walking the distinct rating list, which a connection that cannot multiplex commands rejects.
await InsertRatedItemAsync(dataSource, UnratedItemId, null, cancellationToken);
await InsertRatedItemAsync(dataSource, EmptyRatingItemId, string.Empty, cancellationToken);
await InsertRatedItemAsync(dataSource, RatedItemId, "PG-13", cancellationToken);
var localizationManager = new Mock<ILocalizationManager>();
localizationManager
.Setup(manager => manager.GetRatingScore("PG-13", null))
.Returns(new ParentalRatingScore(13, 2));
new MigrateRatingLevels(
new SingleContextFactory(dataSource),
new NullStartupLogger<MigrateRatingLevels>(),
localizationManager.Object).Perform();
Assert.Null(await InheritedRatingAsync(dataSource, UnratedItemId, "InheritedParentalRatingValue", cancellationToken));
Assert.Null(await InheritedRatingAsync(dataSource, UnratedItemId, "InheritedParentalRatingSubValue", cancellationToken));
Assert.Null(await InheritedRatingAsync(dataSource, EmptyRatingItemId, "InheritedParentalRatingValue", cancellationToken));
Assert.Null(await InheritedRatingAsync(dataSource, EmptyRatingItemId, "InheritedParentalRatingSubValue", cancellationToken));
Assert.Equal(13, await InheritedRatingAsync(dataSource, RatedItemId, "InheritedParentalRatingValue", cancellationToken));
Assert.Equal(2, await InheritedRatingAsync(dataSource, RatedItemId, "InheritedParentalRatingSubValue", cancellationToken));
}
[Fact]
public async Task UniqueUsernameIndex_CannotBeAppliedBeforeTheUsernameCodeMigration()
{
@@ -411,6 +450,34 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
private static string Literal(string? value) => value is null ? "NULL" : $"'{value}'";
/// <summary>
/// Adds an item carrying a rating and an already populated inherited rating, so that clearing it is visible.
/// </summary>
private static Task InsertRatedItemAsync(
NpgsqlDataSource dataSource,
string itemId,
string? officialRating,
CancellationToken cancellationToken)
=> ExecuteAsync(
dataSource,
$"""
INSERT INTO "BaseItems" ("Id", "Type", "IsMovie", "IsLocked", "IsSeries", "IsRepeat", "IsInMixedFolder",
"IsFolder", "IsVirtualItem", "Name", "Path", "OfficialRating",
"InheritedParentalRatingValue", "InheritedParentalRatingSubValue")
VALUES ('{itemId}', 'MediaBrowser.Controller.Entities.Movies.Movie', true, false, false, false, false, false, false,
'Rated {itemId}', '/media/{itemId}.mkv', {Literal(officialRating)}, 99, 98);
""",
cancellationToken);
private static async Task<int?> InheritedRatingAsync(NpgsqlDataSource dataSource, string itemId, string column, CancellationToken cancellationToken)
{
var value = await NullableScalarAsync(
dataSource,
$"""SELECT "{column}" FROM "BaseItems" WHERE "Id" = '{itemId}'""",
cancellationToken).ConfigureAwait(false);
return (int?)value;
}
/// <summary>
/// Builds a database that looks like one written by the previous build: only the baseline migration applied, and
/// data that only the old column types could hold.
@@ -555,6 +622,36 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
return new NpgsqlConnectionStringBuilder(_container.GetConnectionString()) { Database = name }.ConnectionString;
}
/// <summary>
/// Stands in for the startup logger. Moq cannot proxy a logger whose category is an internal migration.
/// </summary>
private sealed class NullStartupLogger<TCategory> : IStartupLogger<TCategory>
{
public StartupLogTopic? Topic => null;
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
=> NullLogger.Instance.BeginScope(state);
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
}
IStartupLogger IStartupLogger.BeginGroup(FormattableString logEntry) => this;
IStartupLogger<TOther> IStartupLogger.BeginGroup<TOther>(FormattableString logEntry) => new NullStartupLogger<TOther>();
IStartupLogger<TCategory> IStartupLogger<TCategory>.BeginGroup(FormattableString logEntry) => this;
IStartupLogger IStartupLogger.With(Microsoft.Extensions.Logging.ILogger logger) => this;
IStartupLogger<TOther> IStartupLogger.With<TOther>(Microsoft.Extensions.Logging.ILogger logger) => new NullStartupLogger<TOther>();
IStartupLogger<TCategory> IStartupLogger<TCategory>.With(Microsoft.Extensions.Logging.ILogger logger) => this;
}
private sealed class SingleContextFactory : IDbContextFactory<JellyfinDbContext>
{
private readonly NpgsqlDataSource _dataSource;