Backport pull request #18280 from jellyfin/release-12.z

Only propagate a changed rating to children that follow it

Original-merge: 1d5590d02a

Merged-by: crobibero <cody@robibe.ro>

Backported-by: Cody Robibero <cody@robibe.ro>
This commit is contained in:
Shadowghost authored and Cody Robibero committed 2026-10-05 19:19:08 -04:00
1 parent 3114520c87
commit 35cee77a98
2 files changed
+110 -9

No files matched your search

@@ -292,6 +292,8 @@ public class ItemUpdateController : BaseJellyfinApiController
item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null;
item.ProductionYear = request.ProductionYear;
var previousOfficialRating = item.OfficialRating;
var previousCustomRating = item.CustomRating;
request.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating;
item.OfficialRating = request.OfficialRating;
item.CustomRating = request.CustomRating;
@@ -318,12 +320,14 @@ public class ItemUpdateController : BaseJellyfinApiController
{
season.SeriesName = rseries.Name;
var previousSeasonOfficialRating = season.OfficialRating;
var previousSeasonCustomRating = season.CustomRating;
if (!season.LockedFields.Contains(MetadataField.OfficialRating))
{
season.OfficialRating = request.OfficialRating;
season.OfficialRating = GetPropagatedRating(season.OfficialRating, previousOfficialRating, request.OfficialRating);
}
season.CustomRating = request.CustomRating;
season.CustomRating = GetPropagatedRating(season.CustomRating, previousCustomRating, request.CustomRating);
if (!season.LockedFields.Contains(MetadataField.Tags))
{
@@ -339,10 +343,10 @@ public class ItemUpdateController : BaseJellyfinApiController
if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
{
ep.OfficialRating = request.OfficialRating;
ep.OfficialRating = GetPropagatedRating(ep.OfficialRating, previousSeasonOfficialRating, season.OfficialRating);
}
ep.CustomRating = request.CustomRating;
ep.CustomRating = GetPropagatedRating(ep.CustomRating, previousSeasonCustomRating, season.CustomRating);
if (!ep.LockedFields.Contains(MetadataField.Tags))
{
@@ -362,10 +366,10 @@ public class ItemUpdateController : BaseJellyfinApiController
if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
{
ep.OfficialRating = request.OfficialRating;
ep.OfficialRating = GetPropagatedRating(ep.OfficialRating, previousOfficialRating, request.OfficialRating);
}
ep.CustomRating = request.CustomRating;
ep.CustomRating = GetPropagatedRating(ep.CustomRating, previousCustomRating, request.CustomRating);
if (!ep.LockedFields.Contains(MetadataField.Tags))
{
@@ -382,10 +386,10 @@ public class ItemUpdateController : BaseJellyfinApiController
{
if (!track.LockedFields.Contains(MetadataField.OfficialRating))
{
track.OfficialRating = request.OfficialRating;
track.OfficialRating = GetPropagatedRating(track.OfficialRating, previousOfficialRating, request.OfficialRating);
}
track.CustomRating = request.CustomRating;
track.CustomRating = GetPropagatedRating(track.CustomRating, previousCustomRating, request.CustomRating);
if (!track.LockedFields.Contains(MetadataField.Tags))
{
@@ -477,6 +481,23 @@ public class ItemUpdateController : BaseJellyfinApiController
}
}
/// <summary>
/// Gets the rating a child should carry after its parent's rating changed from <paramref name="previousRating"/>
/// to <paramref name="newRating"/>: a child with no rating, or the parent's previous one, follows the parent.
/// A child with a rating of its own, or any child when the parent's rating did not change, keeps its rating.
/// </summary>
private static string? GetPropagatedRating(string? childRating, string? previousRating, string? newRating)
{
if (string.Equals(previousRating ?? string.Empty, newRating ?? string.Empty, StringComparison.OrdinalIgnoreCase))
{
return childRating;
}
return string.IsNullOrEmpty(childRating) || string.Equals(childRating, previousRating, StringComparison.OrdinalIgnoreCase)
? newRating
: childRating;
}
private SeriesStatus? GetSeriesStatus(BaseItemDto item)
{
if (string.IsNullOrEmpty(item.Status))
@@ -4,9 +4,11 @@ using Jellyfin.Api.Controllers;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using Moq;
@@ -14,12 +16,21 @@ using Xunit;
namespace Jellyfin.Api.Tests.Controllers;
public class ItemUpdateControllerTests
[Collection("LibraryManagerTests")]
public sealed class ItemUpdateControllerTests : IDisposable
{
private readonly ItemUpdateController _subject;
private readonly ILibraryManager? _previousLibraryManager;
private readonly ILocalizationManager? _previousLocalizationManager;
public ItemUpdateControllerTests()
{
// Children are scored and saved through BaseItem's process-wide statics; restored in Dispose.
_previousLibraryManager = BaseItem.LibraryManager;
_previousLocalizationManager = BaseItem.LocalizationManager;
BaseItem.LibraryManager = Mock.Of<ILibraryManager>();
BaseItem.LocalizationManager = Mock.Of<ILocalizationManager>();
_subject = new ItemUpdateController(
Mock.Of<IFileSystem>(),
Mock.Of<ILibraryManager>(),
@@ -70,6 +81,75 @@ public class ItemUpdateControllerTests
Assert.Equal("tt1234567", movie.ProviderIds["Imdb"]);
}
public void Dispose()
{
BaseItem.LibraryManager = _previousLibraryManager!;
BaseItem.LocalizationManager = _previousLocalizationManager!;
}
[Fact]
public async Task UpdateItem_SeriesRatingUnchanged_KeepsEpisodeRatings()
{
// Regression test for https://github.com/jellyfin/jellyfin/issues/18241
var (series, season, episodes) = CreateSeries("TV-PG", "TV-PG", "TV-14", null);
await InvokeUpdateItem(new BaseItemDto { Overview = "New overview", OfficialRating = "TV-PG" }, series);
Assert.Equal("TV-PG", season.OfficialRating);
Assert.Equal("TV-14", episodes[1].OfficialRating);
Assert.Null(episodes[2].OfficialRating);
}
[Fact]
public async Task UpdateItem_SeriesRatingChanged_PropagatesOnlyToChildrenFollowingIt()
{
var (series, season, episodes) = CreateSeries("TV-PG", "TV-PG", "TV-14", null);
var locked = new Episode { OfficialRating = "TV-PG", PreferredMetadataCountryCode = "us", LockedFields = [MetadataField.OfficialRating] };
season.Children = [.. episodes, locked];
await InvokeUpdateItem(new BaseItemDto { OfficialRating = "TV-MA" }, series);
Assert.Equal("TV-MA", season.OfficialRating);
Assert.Equal("TV-MA", episodes[0].OfficialRating);
Assert.Equal("TV-14", episodes[1].OfficialRating);
Assert.Equal("TV-MA", episodes[2].OfficialRating);
Assert.Equal("TV-PG", locked.OfficialRating);
}
[Fact]
public async Task UpdateItem_SeriesCustomRatingChanged_PropagatesIt()
{
var (series, season, episodes) = CreateSeries("TV-PG", "TV-PG", "TV-14", null);
episodes[0].CustomRating = "TV-Y";
await InvokeUpdateItem(new BaseItemDto { OfficialRating = "TV-PG", CustomRating = "XXX" }, series);
Assert.Equal("XXX", season.CustomRating);
Assert.Equal("TV-Y", episodes[0].CustomRating);
Assert.Equal("XXX", episodes[1].CustomRating);
Assert.Equal("TV-14", episodes[1].OfficialRating);
}
[Fact]
public async Task UpdateItem_SeasonRatingChanged_KeepsEpisodesWithTheirOwnRating()
{
var (_, season, episodes) = CreateSeries("TV-PG", "TV-PG", "TV-14", null);
await InvokeUpdateItem(new BaseItemDto { OfficialRating = "TV-MA" }, season);
Assert.Equal("TV-MA", episodes[0].OfficialRating);
Assert.Equal("TV-14", episodes[1].OfficialRating);
Assert.Equal("TV-MA", episodes[2].OfficialRating);
}
private static (Series Series, Season Season, Episode[] Episodes) CreateSeries(string seriesRating, params string?[] episodeRatings)
{
var episodes = Array.ConvertAll(episodeRatings, r => new Episode { OfficialRating = r, PreferredMetadataCountryCode = "us" });
var season = new Season { OfficialRating = seriesRating, PreferredMetadataCountryCode = "us", Children = episodes };
var series = new Series { OfficialRating = seriesRating, PreferredMetadataCountryCode = "us", Children = [season] };
return (series, season, episodes);
}
private Task InvokeUpdateItem(BaseItemDto request, BaseItem item)
{
return _subject.UpdateItem(request, item);