aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Shadowghost@users.noreply.github.com>2026-10-05 19:19:08 -0400
committerCody Robibero <cody@robibe.ro>2026-10-05 19:19:08 -0400
commit35cee77a983c8448cdae0e26e530699820e2f022 (patch)
treec49c295e6220f1814c2a5d20ed458a3d851f1b7b
parent3114520c87579eb5cf150e4d764f59fd32f39412 (diff)
Backport pull request #18280 from jellyfin/release-12.z
Only propagate a changed rating to children that follow it Original-merge: 1d5590d02ac04d857e73a4e4406b0801589d3368 Merged-by: crobibero <cody@robibe.ro> Backported-by: Cody Robibero <cody@robibe.ro>
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs37
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs82
2 files changed, 110 insertions, 9 deletions
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index 4859cb89c0..288efebc4f 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -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))
diff --git a/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs
index 1a91efe4f2..7b37550e29 100644
--- a/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs
+++ b/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs
@@ -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);