From 89af37462ebbe1da078a6f8b4e104475519eab97 Mon Sep 17 00:00:00 2001 From: Jellyfrog Date: Tue, 15 Sep 2026 11:13:49 -0400 Subject: Backport pull request #17844 from jellyfin/release-12.z Match parental ratings case-insensitively Original-merge: 7d0a33b76252d5d3fcb5d3b10cbedd7658623949 Merged-by: crobibero Backported-by: Cody Robibero --- .../Localization/LocalizationManager.cs | 29 ++++++++++++++++------ 1 file changed, 22 insertions(+), 7 deletions(-) (limited to 'Emby.Server.Implementations/Localization/LocalizationManager.cs') diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 0331ec39e5..65cb5379ea 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -139,7 +139,7 @@ namespace Emby.Server.Implementations.Localization var ratingSystem = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); - var dict = new Dictionary(); + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); if (ratingSystem.Ratings is not null) { foreach (var ratingEntry in ratingSystem.Ratings) @@ -374,12 +374,25 @@ namespace Emby.Server.Implementations.Localization { ArgumentException.ThrowIfNullOrEmpty(rating); + // Handle unrated content. This has to happen before the split below, + // because some of the unrated values contain a '/' themselves (e.g. "n/a"). + if (IsUnrated(rating)) + { + return null; + } + // Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år"). // Try each one in order and use the first that resolves. var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (var ratingValue in ratingValues) { + // A single entry of such a list may be unrated while a later one still resolves + if (IsUnrated(ratingValue)) + { + continue; + } + var score = GetSingleRatingScore(ratingValue, countryCode); if (score is not null) { @@ -390,17 +403,19 @@ namespace Emby.Server.Implementations.Localization return null; } + /// + /// Checks whether a rating value marks the content as unrated. + /// + /// Rating value to check. + /// Returns true if the value is an unrated marker. + private static bool IsUnrated(ReadOnlySpan rating) + => _unratedValues.Contains(rating.Trim(), StringComparison.OrdinalIgnoreCase); + /// /// Resolves a single rating value to a score. /// private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode) { - // Handle unrated content - if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase)) - { - return null; - } - // Convert ints directly // This may override some of the locale specific age ratings (but those always map to the same age) if (TryParseRatingAsScore(rating, out var ratingAge)) -- cgit v1.2.3 From 394facc327a2aeff139279ff35432bcf4755ff36 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:16:01 -0400 Subject: Backport pull request #17933 from jellyfin/release-12.z Fix Italian ratings Original-merge: 003c7b5b9104a1eef6d00c1caf8c9836b277d0e0 Merged-by: crobibero Backported-by: Cody Robibero --- .../Localization/LocalizationManager.cs | 7 +++ .../Localization/Ratings/it.json | 22 +++++-- .../Routines/20260302090000_MigrateRatingLevels.cs | 68 ---------------------- .../Routines/20260910120000_MigrateRatingLevels.cs | 68 ++++++++++++++++++++++ .../Localization/LocalizationManagerTests.cs | 10 ++++ 5 files changed, 103 insertions(+), 72 deletions(-) delete mode 100644 Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs (limited to 'Emby.Server.Implementations/Localization/LocalizationManager.cs') diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 65cb5379ea..c77717c76e 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -139,6 +139,7 @@ namespace Emby.Server.Implementations.Localization var ratingSystem = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); + // Rating strings are compared case insensitively, providers are not consistent about casing (e.g. "VM18" vs "vm18") var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); if (ratingSystem.Ratings is not null) { @@ -527,6 +528,12 @@ namespace Emby.Server.Implementations.Localization return true; } + // Explicitly unrated content (e.g. "IT-NR") is unrated by definition, not a lookup failure + if (IsUnrated(ratingPart)) + { + return true; + } + _logger.LogWarning( "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", rating, diff --git a/Emby.Server.Implementations/Localization/Ratings/it.json b/Emby.Server.Implementations/Localization/Ratings/it.json index f2889bf82c..c1d716a46c 100644 --- a/Emby.Server.Implementations/Localization/Ratings/it.json +++ b/Emby.Server.Implementations/Localization/Ratings/it.json @@ -3,28 +3,42 @@ "supportsSubScores": false, "ratings": [ { - "ratingStrings": ["T"], + "ratingStrings": ["T", "PT", "Per tutti"], "ratingScore": { "score": 0, "subScore": null } }, { - "ratingStrings": ["6+"], + "ratingStrings": ["6+", "VM6", "VM 6", "VM-6", "Vietato ai minori di 6 anni"], "ratingScore": { "score": 6, "subScore": null } }, { - "ratingStrings": ["14+"], + "ratingStrings": ["10+", "VM10", "VM 10", "VM-10", "Vietato ai minori di 10 anni"], + "ratingScore": { + "score": 10, + "subScore": null + } + }, + { + "ratingStrings": ["12+", "VM12", "VM 12", "VM-12", "Vietato ai minori di 12 anni"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["14+", "VM14", "VM 14", "VM-14", "Vietato ai minori di 14 anni"], "ratingScore": { "score": 14, "subScore": null } }, { - "ratingStrings": ["18+"], + "ratingStrings": ["18+", "VM18", "VM 18", "VM-18", "Vietato ai minori di 18 anni"], "ratingScore": { "score": 18, "subScore": null diff --git a/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs deleted file mode 100644 index ed92c34aa3..0000000000 --- a/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Linq; -using Jellyfin.Database.Implementations; -using Jellyfin.Server.ServerSetupApp; -using MediaBrowser.Model.Globalization; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Server.Migrations.Routines; - -/// -/// Migrate rating levels. -/// -#pragma warning disable CS0618 // Type or member is obsolete -[JellyfinMigration("2026-03-02T09:00:00", nameof(MigrateRatingLevels))] -[JellyfinMigrationBackup(JellyfinDb = true)] -#pragma warning restore CS0618 // Type or member is obsolete -internal class MigrateRatingLevels : IDatabaseMigrationRoutine -{ - private readonly IStartupLogger _logger; - private readonly IDbContextFactory _provider; - private readonly ILocalizationManager _localizationManager; - - public MigrateRatingLevels( - IDbContextFactory provider, - IStartupLogger logger, - ILocalizationManager localizationManager) - { - _provider = provider; - _localizationManager = localizationManager; - _logger = logger; - } - - /// - public void Perform() - { - _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(); - foreach (var rating in ratings) - { - if (string.IsNullOrEmpty(rating)) - { - int? value = null; - context.BaseItems - .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty) - .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value)); - context.BaseItems - .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty) - .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value)); - } - else - { - var ratingValue = _localizationManager.GetRatingScore(rating); - var score = ratingValue?.Score; - var subScore = ratingValue?.SubScore; - context.BaseItems - .Where(e => e.OfficialRating == rating) - .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, score)); - context.BaseItems - .Where(e => e.OfficialRating == rating) - .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, subScore)); - } - } - - transaction.Commit(); - } -} diff --git a/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs new file mode 100644 index 0000000000..a456acf47d --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs @@ -0,0 +1,68 @@ +using System.Linq; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Model.Globalization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Migrate rating levels. +/// +#pragma warning disable CS0618 // Type or member is obsolete +[JellyfinMigration("2026-09-10T12:00:00", nameof(MigrateRatingLevels))] +[JellyfinMigrationBackup(JellyfinDb = true)] +#pragma warning restore CS0618 // Type or member is obsolete +internal class MigrateRatingLevels : IDatabaseMigrationRoutine +{ + private readonly IStartupLogger _logger; + private readonly IDbContextFactory _provider; + private readonly ILocalizationManager _localizationManager; + + public MigrateRatingLevels( + IDbContextFactory provider, + IStartupLogger logger, + ILocalizationManager localizationManager) + { + _provider = provider; + _localizationManager = localizationManager; + _logger = logger; + } + + /// + public void Perform() + { + _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(); + foreach (var rating in ratings) + { + if (string.IsNullOrEmpty(rating)) + { + int? value = null; + context.BaseItems + .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty) + .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value)); + context.BaseItems + .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty) + .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value)); + } + else + { + var ratingValue = _localizationManager.GetRatingScore(rating); + var score = ratingValue?.Score; + var subScore = ratingValue?.SubScore; + context.BaseItems + .Where(e => e.OfficialRating == rating) + .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, score)); + context.BaseItems + .Where(e => e.OfficialRating == rating) + .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, subScore)); + } + } + + transaction.Commit(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index ccec4e0037..221f1d8ec8 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -199,6 +199,16 @@ namespace Jellyfin.Server.Implementations.Tests.Localization [InlineData("Rated: R", "US", 17, 0)] [InlineData("Rated R", "US", 17, 0)] [InlineData(" PG-13 ", "US", 13, 0)] + [InlineData("T", "IT", 0, null)] + [InlineData("VM6", "IT", 6, null)] + [InlineData("VM12", "IT", 12, null)] + [InlineData("VM14", "IT", 14, null)] + [InlineData("VM18", "IT", 18, null)] + [InlineData("IT-VM14", "IT", 14, null)] // TMDB style country prefix + [InlineData("IT-VM18", "IT", 18, null)] + [InlineData("it-vm18", "IT", 18, null)] // Rating strings are case insensitive + [InlineData("VM 18", "IT", 18, null)] + [InlineData("Vietato ai minori di 18 anni", "IT", 18, null)] public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore) { var localizationManager = Setup(new ServerConfiguration() -- cgit v1.2.3 From da3031628fda013be20ae4f89906882cc5d72e14 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:16:22 -0400 Subject: Backport pull request #18002 from jellyfin/release-12.z Fix slashed rating handling Original-merge: 4405fccda92426b01903e3fe4c5985f1d7c30957 Merged-by: crobibero Backported-by: Cody Robibero --- .../Localization/LocalizationManager.cs | 12 ++++++++++ .../Entities/AggregateFolderTests.cs | 1 + .../Entities/BaseItemTests.cs | 1 + .../Localization/LocalizationManagerTests.cs | 26 ++++++++++++++++++++++ 4 files changed, 40 insertions(+) (limited to 'Emby.Server.Implementations/Localization/LocalizationManager.cs') diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index c77717c76e..3f89237ab2 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -382,9 +382,21 @@ namespace Emby.Server.Implementations.Localization return null; } + // Several rating systems contain a '/' inside a single rating (e.g. "M/12" in PT, + // "U/A 13+" in IN, "7/i/fig" in ES), so the value as a whole always wins over the split below. + var wholeValueScore = GetSingleRatingScore(rating, countryCode); + if (wholeValueScore is not null) + { + return wholeValueScore; + } + // Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år"). // Try each one in order and use the first that resolves. var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (ratingValues.Length == 1) + { + return null; + } foreach (var ratingValue in ratingValues) { diff --git a/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs index 6c297673d4..272c434fe9 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs @@ -9,6 +9,7 @@ using Xunit; namespace Jellyfin.Controller.Tests.Entities; +[Collection("LibraryManagerTests")] public class AggregateFolderTests { [Fact] diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index f363968909..588f9d5530 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -27,6 +27,7 @@ using Xunit; namespace Jellyfin.Controller.Tests.Entities; +[Collection("LibraryManagerTests")] public class BaseItemTests { [Fact] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 221f1d8ec8..93014e7244 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -296,6 +296,32 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Equal(expectedSubScore, score.SubScore); } + [Theory] + // Ratings that contain a '/' themselves must not be split into a list of ratings + [InlineData("M/3", "pt", 3, null)] + [InlineData("M/12", "pt", 12, null)] + [InlineData("M/18", "pt", 18, null)] + [InlineData("PT-M/12", "pt", 12, null)] // TMDB style country prefix + [InlineData("M/12", "us", 12, null)] // Resolved through the all-systems fallback + [InlineData("U/A 13+", "in", 13, null)] + [InlineData("7/i", "es", 11, null)] + [InlineData("7/i/fig", "es", 11, null)] + [InlineData("18/fig", "es", 18, null)] + public async Task GetRatingScore_RatingContainingSlash_IsNotSplit(string value, string countryCode, int expectedScore, int? expectedSubScore) + { + var localizationManager = Setup(new ServerConfiguration + { + MetadataCountryCode = countryCode + }); + await localizationManager.LoadAll(); + + var score = localizationManager.GetRatingScore(value); + + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); + } + [Theory] [InlineData("-NO RATING SHOWN-")] [InlineData(":NO RATING SHOWN:")] -- cgit v1.2.3 From af2d33355a6dd24e4eea3fe01858df5f55902ef3 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 27 Sep 2026 16:30:33 -0400 Subject: Backport pull request #18057 from jellyfin/release-12.z Fix more rating systems Original-merge: e9f7544fa181658d18dede0dc38276e7d21ba9b0 Merged-by: crobibero Backported-by: Cody Robibero --- .../Localization/LocalizationManager.cs | 42 ++++++--- .../Localization/Ratings/0-prefer.json | 2 +- .../Localization/Ratings/ar.json | 24 +++-- .../Localization/Ratings/au.json | 2 +- .../Localization/Ratings/de.json | 10 +-- .../Localization/Ratings/fr.json | 8 +- .../Localization/Ratings/id.json | 25 +++++- .../Routines/20260910120000_MigrateRatingLevels.cs | 68 -------------- .../Routines/20260915120000_MigrateRatingLevels.cs | 68 ++++++++++++++ .../Localization/LocalizationManagerTests.cs | 100 ++++++++++++++++++++- 10 files changed, 248 insertions(+), 101 deletions(-) delete mode 100644 Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs (limited to 'Emby.Server.Implementations/Localization/LocalizationManager.cs') diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 3f89237ab2..7af545c550 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -398,6 +398,7 @@ namespace Emby.Server.Implementations.Localization return null; } + var hasUnknownRating = false; foreach (var ratingValue in ratingValues) { // A single entry of such a list may be unrated while a later one still resolves @@ -406,11 +407,21 @@ namespace Emby.Server.Implementations.Localization continue; } - var score = GetSingleRatingScore(ratingValue, countryCode); + // An unknown entry is not worth a warning of its own as long as another entry still resolves + var score = GetSingleRatingScore(ratingValue, countryCode, logUnknownRating: false); if (score is not null) { return score; } + + hasUnknownRating = true; + } + + if (hasUnknownRating) + { + _logger.LogWarning( + "None of the ratings in '{Rating}' were found in a known rating system, treating as unrated", + rating); } return null; @@ -427,7 +438,7 @@ namespace Emby.Server.Implementations.Localization /// /// Resolves a single rating value to a score. /// - private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode) + private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode, bool logUnknownRating = true) { // Convert ints directly // This may override some of the locale specific age ratings (but those always map to the same age) @@ -483,9 +494,11 @@ namespace Emby.Server.Implementations.Localization } } - // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18" - if (TryGetRatingScoreBySeparator(rating, ':', out var result) - || TryGetRatingScoreBySeparator(rating, '-', out result)) + // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18". + // A '/' marks a list of ratings, which this would split into the first entry's country and the last entry's rating + if (!rating.Contains('/', StringComparison.Ordinal) + && (TryGetRatingScoreBySeparator(rating, ':', logUnknownRating, out var result) + || TryGetRatingScoreBySeparator(rating, '-', logUnknownRating, out result))) { return result; } @@ -493,7 +506,7 @@ namespace Emby.Server.Implementations.Localization return null; } - private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result) + private bool TryGetRatingScoreBySeparator(string rating, char separator, bool logUnknownRating, out ParentalRatingScore? result) { result = null; @@ -546,10 +559,13 @@ namespace Emby.Server.Implementations.Localization return true; } - _logger.LogWarning( - "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", - rating, - resolvedCountryCode); + if (logUnknownRating) + { + _logger.LogWarning( + "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", + rating, + resolvedCountryCode); + } return true; } @@ -561,14 +577,16 @@ namespace Emby.Server.Implementations.Localization } /// - /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+"). + /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+") + /// or a leading '-' (e.g. the French "-12"). /// /// Rating value to parse. /// Parsed score. /// Returns true if parsing was successful. private static bool TryParseRatingAsScore(ReadOnlySpan ratingValue, out int score) { - var trimmed = ratingValue.TrimEnd('+'); + // A leading '-' marks a minimum age ("-12" is French for "not for under 12s"), never a negative score + var trimmed = ratingValue.TrimStart('-').TrimEnd('+'); return int.TryParse(trimmed, out score); } diff --git a/Emby.Server.Implementations/Localization/Ratings/0-prefer.json b/Emby.Server.Implementations/Localization/Ratings/0-prefer.json index b390151611..dd88b35363 100644 --- a/Emby.Server.Implementations/Localization/Ratings/0-prefer.json +++ b/Emby.Server.Implementations/Localization/Ratings/0-prefer.json @@ -24,7 +24,7 @@ } }, { - "ratingStrings": ["X", "XX", "XXX", "XXXX"], + "ratingStrings": ["X", "XX", "XXX", "XXXX", "P"], "ratingScore": { "score": 1000, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/ar.json b/Emby.Server.Implementations/Localization/Ratings/ar.json index 73dfd2c7c9..20053fe575 100644 --- a/Emby.Server.Implementations/Localization/Ratings/ar.json +++ b/Emby.Server.Implementations/Localization/Ratings/ar.json @@ -3,35 +3,49 @@ "supportsSubScores": false, "ratings": [ { - "ratingStrings": ["ATP"], + "ratingStrings": ["ATP", "Apta para todo público", "G"], "ratingScore": { "score": 0, "subScore": null } }, { - "ratingStrings": ["+13"], + "ratingStrings": ["SP"], + "ratingScore": { + "score": 10, + "subScore": null + } + }, + { + "ratingStrings": ["+13", "SAM 13", "SAM13", "R-13"], "ratingScore": { "score": 13, "subScore": null } }, { - "ratingStrings": ["+16"], + "ratingStrings": ["+16", "SAM 16", "SAM16"], "ratingScore": { "score": 16, "subScore": null } }, { - "ratingStrings": ["+18"], + "ratingStrings": ["R-17"], + "ratingScore": { + "score": 17, + "subScore": null + } + }, + { + "ratingStrings": ["+18", "SAM 18", "SAM18"], "ratingScore": { "score": 18, "subScore": null } }, { - "ratingStrings": ["C"], + "ratingStrings": ["C", "+18 C", "SAM 18 C", "SAM 18C", "SAM18C"], "ratingScore": { "score": 1001, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/au.json b/Emby.Server.Implementations/Localization/Ratings/au.json index a563df899d..c5d4c97443 100644 --- a/Emby.Server.Implementations/Localization/Ratings/au.json +++ b/Emby.Server.Implementations/Localization/Ratings/au.json @@ -52,7 +52,7 @@ } }, { - "ratingStrings": ["X", "X18", "X 18"], + "ratingStrings": ["X", "X18", "X 18", "X18+", "X 18+"], "ratingScore": { "score": 1000, "subScore": 0 diff --git a/Emby.Server.Implementations/Localization/Ratings/de.json b/Emby.Server.Implementations/Localization/Ratings/de.json index 30c34b230c..2a925f999f 100644 --- a/Emby.Server.Implementations/Localization/Ratings/de.json +++ b/Emby.Server.Implementations/Localization/Ratings/de.json @@ -3,35 +3,35 @@ "supportsSubScores": false, "ratings": [ { - "ratingStrings": ["0", "FSK 0", "FSK-0", "Educational", "Infoprogramm"], + "ratingStrings": ["0", "FSK 0", "FSK-0", "FSK0", "ab 0", "Educational", "Infoprogramm"], "ratingScore": { "score": 0, "subScore": null } }, { - "ratingStrings": ["6", "FSK 6", "FSK-6"], + "ratingStrings": ["6", "FSK 6", "FSK-6", "FSK6", "ab 6"], "ratingScore": { "score": 6, "subScore": null } }, { - "ratingStrings": ["12", "FSK 12", "FSK-12"], + "ratingStrings": ["12", "FSK 12", "FSK-12", "FSK12", "ab 12"], "ratingScore": { "score": 12, "subScore": null } }, { - "ratingStrings": ["16", "FSK 16", "FSK-16"], + "ratingStrings": ["16", "FSK 16", "FSK-16", "FSK16", "ab 16"], "ratingScore": { "score": 16, "subScore": null } }, { - "ratingStrings": ["18", "FSK 18", "FSK-18"], + "ratingStrings": ["18", "FSK 18", "FSK-18", "FSK18", "ab 18"], "ratingScore": { "score": 18, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/fr.json b/Emby.Server.Implementations/Localization/Ratings/fr.json index e8bafd6b87..333e467a08 100644 --- a/Emby.Server.Implementations/Localization/Ratings/fr.json +++ b/Emby.Server.Implementations/Localization/Ratings/fr.json @@ -24,14 +24,14 @@ } }, { - "ratingStrings": ["10"], + "ratingStrings": ["10", "Interdit aux moins de 10 ans"], "ratingScore": { "score": 10, "subScore": null } }, { - "ratingStrings": ["12"], + "ratingStrings": ["12", "–12", "Interdit aux moins de 12 ans"], "ratingScore": { "score": 12, "subScore": null @@ -45,14 +45,14 @@ } }, { - "ratingStrings": ["16"], + "ratingStrings": ["16", "–16", "Interdit aux moins de 16 ans"], "ratingScore": { "score": 16, "subScore": null } }, { - "ratingStrings": ["18"], + "ratingStrings": ["18", "–18", "Interdit aux moins de 18 ans"], "ratingScore": { "score": 18, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/id.json b/Emby.Server.Implementations/Localization/Ratings/id.json index 8c687c2320..f55e4e4333 100644 --- a/Emby.Server.Implementations/Localization/Ratings/id.json +++ b/Emby.Server.Implementations/Localization/Ratings/id.json @@ -3,14 +3,28 @@ "supportsSubScores": false, "ratings": [ { - "ratingStrings": ["SU"], + "ratingStrings": ["SU", "SU-BO", "Semua Umur"], "ratingScore": { "score": 0, "subScore": null } }, { - "ratingStrings": ["13+"], + "ratingStrings": ["P", "P2+", "P-BO", "Prasekolah"], + "ratingScore": { + "score": 2, + "subScore": null + } + }, + { + "ratingStrings": ["A", "A7+", "A-BO", "Anak"], + "ratingScore": { + "score": 7, + "subScore": null + } + }, + { + "ratingStrings": ["13+", "R", "R13+", "R-BO", "Remaja"], "ratingScore": { "score": 13, "subScore": null @@ -23,6 +37,13 @@ "subScore": null } }, + { + "ratingStrings": ["D", "D18+", "Dewasa"], + "ratingScore": { + "score": 18, + "subScore": null + } + }, { "ratingStrings": ["21+"], "ratingScore": { diff --git a/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs deleted file mode 100644 index a456acf47d..0000000000 --- a/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Linq; -using Jellyfin.Database.Implementations; -using Jellyfin.Server.ServerSetupApp; -using MediaBrowser.Model.Globalization; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Server.Migrations.Routines; - -/// -/// Migrate rating levels. -/// -#pragma warning disable CS0618 // Type or member is obsolete -[JellyfinMigration("2026-09-10T12:00:00", nameof(MigrateRatingLevels))] -[JellyfinMigrationBackup(JellyfinDb = true)] -#pragma warning restore CS0618 // Type or member is obsolete -internal class MigrateRatingLevels : IDatabaseMigrationRoutine -{ - private readonly IStartupLogger _logger; - private readonly IDbContextFactory _provider; - private readonly ILocalizationManager _localizationManager; - - public MigrateRatingLevels( - IDbContextFactory provider, - IStartupLogger logger, - ILocalizationManager localizationManager) - { - _provider = provider; - _localizationManager = localizationManager; - _logger = logger; - } - - /// - public void Perform() - { - _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(); - foreach (var rating in ratings) - { - if (string.IsNullOrEmpty(rating)) - { - int? value = null; - context.BaseItems - .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty) - .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value)); - context.BaseItems - .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty) - .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value)); - } - else - { - var ratingValue = _localizationManager.GetRatingScore(rating); - var score = ratingValue?.Score; - var subScore = ratingValue?.SubScore; - context.BaseItems - .Where(e => e.OfficialRating == rating) - .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, score)); - context.BaseItems - .Where(e => e.OfficialRating == rating) - .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, subScore)); - } - } - - transaction.Commit(); - } -} diff --git a/Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs new file mode 100644 index 0000000000..903d91e046 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs @@ -0,0 +1,68 @@ +using System.Linq; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Model.Globalization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Migrate rating levels. +/// +#pragma warning disable CS0618 // Type or member is obsolete +[JellyfinMigration("2026-09-15T12:00:00", nameof(MigrateRatingLevels))] +[JellyfinMigrationBackup(JellyfinDb = true)] +#pragma warning restore CS0618 // Type or member is obsolete +internal class MigrateRatingLevels : IDatabaseMigrationRoutine +{ + private readonly IStartupLogger _logger; + private readonly IDbContextFactory _provider; + private readonly ILocalizationManager _localizationManager; + + public MigrateRatingLevels( + IDbContextFactory provider, + IStartupLogger logger, + ILocalizationManager localizationManager) + { + _provider = provider; + _localizationManager = localizationManager; + _logger = logger; + } + + /// + public void Perform() + { + _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(); + foreach (var rating in ratings) + { + if (string.IsNullOrEmpty(rating)) + { + int? value = null; + context.BaseItems + .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty) + .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value)); + context.BaseItems + .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty) + .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value)); + } + else + { + var ratingValue = _localizationManager.GetRatingScore(rating); + var score = ratingValue?.Score; + var subScore = ratingValue?.SubScore; + context.BaseItems + .Where(e => e.OfficialRating == rating) + .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, score)); + context.BaseItems + .Where(e => e.OfficialRating == rating) + .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, subScore)); + } + } + + transaction.Commit(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 93014e7244..4e57f32b57 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -6,6 +6,7 @@ using BitFaster.Caching; using Emby.Server.Implementations.Localization; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -180,7 +181,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization await localizationManager.LoadAll(); var ratings = localizationManager.GetParentalRatings().ToList(); - Assert.Equal(24, ratings.Count); + Assert.Equal(34, ratings.Count); var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal)); Assert.NotNull(fsk); @@ -209,6 +210,37 @@ namespace Jellyfin.Server.Implementations.Tests.Localization [InlineData("it-vm18", "IT", 18, null)] // Rating strings are case insensitive [InlineData("VM 18", "IT", 18, null)] [InlineData("Vietato ai minori di 18 anni", "IT", 18, null)] + [InlineData("ATP", "AR", 0, null)] + [InlineData("SAM 13", "AR", 13, null)] + [InlineData("SAM 16", "AR", 16, null)] + [InlineData("SAM 18", "AR", 18, null)] + [InlineData("SAM13", "AR", 13, null)] // Written without a space + [InlineData("AR-SAM 16", "AR", 16, null)] // Country prefix stripped against the configured country + [InlineData("AR-SAM 13", "US", 13, null)] // Country prefix resolved via the separator fallback + [InlineData("AR-SAM 18", "US", 18, null)] + [InlineData("AR-SAM13", "US", 13, null)] + [InlineData("SAM 18 C", "AR", 1001, null)] // Condicionada, same as "C" + [InlineData("Interdit aux moins de 12 ans", "FR", 12, null)] + [InlineData("Interdit aux moins de 18 ans", "FR", 18, null)] + [InlineData("X 18+", "AU", 1000, 0)] // Official spelling of the Australian X rating + [InlineData("X18+", "AU", 1000, 0)] + [InlineData("FSK18", "DE", 18, null)] // Written without a space + [InlineData("ab 18", "DE", 18, null)] // Written as the minimum age + [InlineData("DE:ab 6", "DE", 6, null)] + [InlineData("–12", "FR", 12, null)] // The CNC writes its minimum ages with an en dash + [InlineData("–16", "FR", 16, null)] + [InlineData("–18", "FR", 18, null)] + [InlineData("SU", "ID", 0, null)] // Indonesian broadcast classifications (KPI) + [InlineData("P", "ID", 2, null)] + [InlineData("A", "ID", 7, null)] + [InlineData("R", "ID", 13, null)] + [InlineData("D", "ID", 18, null)] + [InlineData("D18+", "ID", 18, null)] // Written with the minimum age, as broadcast since 2016 + [InlineData("R-BO", "ID", 13, null)] // Parental guidance does not change the age group + [InlineData("Dewasa", "ID", 18, null)] + [InlineData("ID-D", "ID", 18, null)] // TMDB style country prefix + [InlineData("ID-D", "US", 18, null)] // Country prefix resolved via the separator fallback + [InlineData("P", "US", 1000, null)] // Ambiguous outside Indonesia, preferred as the Portuguese "Pornográfico" public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore) { var localizationManager = Setup(new ServerConfiguration() @@ -253,6 +285,12 @@ namespace Jellyfin.Server.Implementations.Tests.Localization [InlineData("12", 12, null)] [InlineData("42", 42, null)] [InlineData("9999", 9999, null)] + // The French CNC writes minimum ages as "-12" ("not for under 12s"). Parsing that as -12 would + // put the item below every MaxParentalRatingScore and bypass parental control entirely. + [InlineData("-10", 10, null)] + [InlineData("-12", 12, null)] + [InlineData("-16", 16, null)] + [InlineData("-18", 18, null)] public async Task GetRatingLevel_GivenValidAge_Success(string value, int? expectedScore, int? expectedSubScore) { var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "nl" }); @@ -322,6 +360,62 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Equal(expectedSubScore, score.SubScore); } + [Theory] + // Providers list every spelling of a rating in a single field. Splitting such a list by its country + // prefix pairs the first entry's country with the last entry's rating, so it has to be split by '/' first. + [InlineData("DE:FSK 18 / DE:FSK-18 / DE:FSK18 / DE:18 / DE:ab 18", "de", 18, null)] + [InlineData("SE:15 / SE:15+ / SE:Från 15 år", "de", 15, null)] + [InlineData("FR:16 / US:12", "de", 16, null)] // The first entry that resolves wins + public async Task GetRatingScore_CountryPrefixedList_UsesFirstResolvingEntry(string value, string countryCode, int expectedScore, int? expectedSubScore) + { + var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = countryCode }); + await localizationManager.LoadAll(); + + var score = localizationManager.GetRatingScore(value); + + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); + } + + [Fact] + public async Task GetRatingScore_ResolvedCountryPrefixedList_DoesNotWarn() + { + var logger = new Mock>(); + var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "de" }, logger.Object); + await localizationManager.LoadAll(); + + Assert.NotNull(localizationManager.GetRatingScore("DE:FSK 18 / DE:FSK-18 / DE:FSK18 / DE:18 / DE:ab 18")); + + logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Never); + } + + [Fact] + public async Task GetRatingScore_ListWithoutKnownRating_WarnsOnce() + { + var logger = new Mock>(); + var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "de" }, logger.Object); + await localizationManager.LoadAll(); + + Assert.Null(localizationManager.GetRatingScore("DE:Unbekannt / DE:Unsinn")); + + logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + [Theory] [InlineData("-NO RATING SHOWN-")] [InlineData(":NO RATING SHOWN:")] @@ -525,12 +619,12 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Contains(supported, c => c.Name.Equals("es-419", StringComparison.OrdinalIgnoreCase)); } - private LocalizationManager Setup(ServerConfiguration config) + private LocalizationManager Setup(ServerConfiguration config, ILogger? logger = null) { var mockConfiguration = new Mock(); mockConfiguration.SetupGet(x => x.Configuration).Returns(config); - return new LocalizationManager(mockConfiguration.Object, new NullLogger()); + return new LocalizationManager(mockConfiguration.Object, logger ?? new NullLogger()); } } } -- cgit v1.2.3