using System.Collections.Frozen; using Jellyfin.Plugin.Audience.Configuration; using MediaBrowser.Controller.Entities; namespace Jellyfin.Plugin.Audience; /// /// The selection and rating rules, kept free of Jellyfin service dependencies so they /// can be reasoned about and tested on their own. /// public static class AudienceRules { /// /// Ratings Jellyfin cannot parse, mapped to the US rating that means the same thing. /// /// /// Every entry is a documented equivalence between national classification boards, /// NOT a judgement about content. Guessing a rating from genre or synopsis is refused /// on purpose: a wrong guess fails OPEN, which is the exact failure mode parental /// controls exist to prevent. /// /// The mapping cuts both ways. It hides adult Singapore-rated titles that were /// previously visible to every account, and it rescues genuinely all-ages titles /// (Spain's TP, Brazil's L) that would otherwise be blocked as unrated. /// public static readonly FrozenDictionary RatingRemap = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["GP"] = "PG", // US MPAA 1970-72, the direct predecessor of PG ["PG13"] = "PG-13", // plain typo for PG-13 ["M"] = "TV-14", // Australia, Mature, recommended 15+ ["TP"] = "TV-G", // Spain, Todos los Publicos, all ages ["L"] = "TV-G", // Brazil, Livre, all ages ["B"] = "TV-PG", // Mexico, 12+ ["B-15"] = "TV-14", // Mexico, 15+ ["VM14"] = "TV-14", // Italy, Vietato ai Minori di 14 ["NC16"] = "TV-MA", // Singapore, 16+ ["M18"] = "TV-MA", // Singapore, 18+ ["R21"] = "TV-MA", // Singapore, 21+ ["PASSED"] = "Approved" // pre-1968 US Production Code seal }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); /// /// Genres that disqualify an item outright. /// public static readonly FrozenSet HarshGenres = new[] { "Horror", "Thriller", "War", "Science Fiction", "Sci-Fi & Fantasy", "Animation", "Anime", "Action", "Action & Adventure", "Crime", "Reality", "Talk", "News" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); /// /// Genres an item must have at least one of to qualify. /// public static readonly FrozenSet GentleGenres = new[] { "Comedy", "Drama", "Romance", "Family", "Music", "Western", "Mystery", "Documentary", "History", "Soap", "Kids" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); /// /// Values that Jellyfin itself treats as "no rating". /// private static readonly FrozenSet UnratedValues = new[] { "n/a", "unrated", "not rated", "nr" } .ToFrozenSet(StringComparer.OrdinalIgnoreCase); /// /// Returns the replacement rating for an unparseable value, or null to leave it alone. /// public static string? RemapRating(string? officialRating) { if (string.IsNullOrWhiteSpace(officialRating)) { return null; } return RatingRemap.TryGetValue(officialRating.Trim(), out var mapped) ? mapped : null; } /// /// Returns true when the value is one Jellyfin resolves to "unrated". /// public static bool IsUnratedValue(string? officialRating) => string.IsNullOrWhiteSpace(officialRating) || UnratedValues.Contains(officialRating.Trim()); /// /// Decides whether an item belongs in the reduced library. /// /// The item under test. /// /// The score from Jellyfin's localisation manager, or null when the rating is absent /// or unrecognised. Passed in rather than resolved here so this stays dependency-free. /// /// Plugin settings. public static bool WantsTag(BaseItem item, int? ratingScore, PluginConfiguration config) { var genres = item.Genres; if (genres is null || genres.Length == 0) { return false; } foreach (var genre in genres) { if (HarshGenres.Contains(genre)) { return false; } } var hasGentle = false; foreach (var genre in genres) { if (GentleGenres.Contains(genre)) { hasGentle = true; break; } } if (!hasGentle) { return false; } // A usable rating must clear the ceiling AND the quality floor. The floor matters // as much as the ceiling: the point of this tag is to make an overwhelming library // smaller, and a poorly reviewed film that happens to be rated PG adds noise rather // than removing it. An item with no community rating at all fails here, which is // deliberate - without a rating and without a score there is nothing to judge on. if (ratingScore.HasValue) { return ratingScore.Value <= config.MaxRatingScore && item.CommunityRating.HasValue && item.CommunityRating.Value >= config.MinCommunityRating; } // No usable rating: fall back to reputation alone, against its own floor. This is // what keeps the older classics, which are exactly what this audience wants and // which TMDB frequently has no US certification for. The floor is separate because // an unrated item is carrying more risk, so it may warrant a higher bar. return item.CommunityRating.HasValue && item.CommunityRating.Value >= config.MinCommunityRatingWhenUnrated; } }