aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Shadowghost@users.noreply.github.com>2026-09-27 16:30:33 -0400
committerCody Robibero <cody@robibe.ro>2026-09-27 16:30:33 -0400
commitaf2d33355a6dd24e4eea3fe01858df5f55902ef3 (patch)
treeb4c0b43bb949aa540b03ed12c046c77ce8f18738
parent64befbcdaa2718468fc015cc8224c8d71ddd001c (diff)
Backport pull request #18057 from jellyfin/release-12.z
Fix more rating systems Original-merge: e9f7544fa181658d18dede0dc38276e7d21ba9b0 Merged-by: crobibero <cody@robibe.ro> Backported-by: Cody Robibero <cody@robibe.ro>
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs42
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/0-prefer.json2
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/ar.json24
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/au.json2
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/de.json10
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/fr.json8
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/id.json25
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs (renamed from Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs)2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs100
9 files changed, 181 insertions, 34 deletions
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
/// <summary>
/// Resolves a single rating value to a score.
/// </summary>
- 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
}
/// <summary>
- /// 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").
/// </summary>
/// <param name="ratingValue">Rating value to parse.</param>
/// <param name="score">Parsed score.</param>
/// <returns>Returns true if parsing was successful.</returns>
private static bool TryParseRatingAsScore(ReadOnlySpan<char> 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
@@ -24,6 +38,13 @@
}
},
{
+ "ratingStrings": ["D", "D18+", "Dewasa"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ },
+ {
"ratingStrings": ["21+"],
"ratingScore": {
"score": 21,
diff --git a/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs
index a456acf47d..903d91e046 100644
--- a/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs
@@ -11,7 +11,7 @@ namespace Jellyfin.Server.Migrations.Routines;
/// Migrate rating levels.
/// </summary>
#pragma warning disable CS0618 // Type or member is obsolete
-[JellyfinMigration("2026-09-10T12:00:00", nameof(MigrateRatingLevels))]
+[JellyfinMigration("2026-09-15T12:00:00", nameof(MigrateRatingLevels))]
[JellyfinMigrationBackup(JellyfinDb = true)]
#pragma warning restore CS0618 // Type or member is obsolete
internal class MigrateRatingLevels : IDatabaseMigrationRoutine
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" });
@@ -323,6 +361,62 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
[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<ILogger<LocalizationManager>>();
+ 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<EventId>(),
+ It.IsAny<It.IsAnyType>(),
+ It.IsAny<Exception?>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task GetRatingScore_ListWithoutKnownRating_WarnsOnce()
+ {
+ var logger = new Mock<ILogger<LocalizationManager>>();
+ 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<EventId>(),
+ It.IsAny<It.IsAnyType>(),
+ It.IsAny<Exception?>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
+ }
+
+ [Theory]
[InlineData("-NO RATING SHOWN-")]
[InlineData(":NO RATING SHOWN:")]
public async Task GetRatingLevel_Split_Success(string value)
@@ -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<LocalizationManager>? logger = null)
{
var mockConfiguration = new Mock<IServerConfigurationManager>();
mockConfiguration.SetupGet(x => x.Configuration).Returns(config);
- return new LocalizationManager(mockConfiguration.Object, new NullLogger<LocalizationManager>());
+ return new LocalizationManager(mockConfiguration.Object, logger ?? new NullLogger<LocalizationManager>());
}
}
}