using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Audience; /// /// Nightly maintenance: repair unparseable parental ratings, then keep the audience /// tag current as new content arrives. /// /// /// Running in-process is the whole point. The REST equivalent (POST /Items/{id}) carries /// three traps that do not exist here: /// 1. Name, Overview, ProductionYear, OfficialRating and CustomRating are assigned /// unconditionally, so omitting one WIPES it. /// 2. IsLocked = request.LockData ?? false, so omitting LockData silently unlocks. /// 3. TrickplayInfoDto serializes but does not deserialize in 12.0, so a read-modify-write /// round trip returns HTTP 500 for any item with trickplay thumbnails. The signature is /// misleading: every Movie fails while every Series succeeds, which reads like a /// permissions problem and is not. /// Mutating the entity and calling UpdateToRepositoryAsync touches only what was changed, /// and needs no API key, no drop-file and no container. /// public class AudienceTask : IScheduledTask { private readonly ILibraryManager _libraryManager; private readonly ILocalizationManager _localization; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// public AudienceTask( ILibraryManager libraryManager, ILocalizationManager localization, ILogger logger) { _libraryManager = libraryManager; _localization = localization; _logger = logger; } /// public string Name => "Repair ratings and apply audience tags"; /// public string Key => "AudienceMaintain"; /// public string Description => "Rewrites parental ratings Jellyfin cannot parse to their US equivalent, then " + "applies the audience tag to newly added titles that match the selection rules."; /// public string Category => "Audience"; /// public IEnumerable GetDefaultTriggers() { yield return new TaskTriggerInfo { Type = TaskTriggerInfoType.DailyTrigger, TimeOfDayTicks = TimeSpan.FromHours(5).Ticks }; } /// public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) { var config = Plugin.Instance?.Configuration; if (config is null || !config.Enabled) { _logger.LogInformation("Audience task is disabled; nothing to do."); return; } var items = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Series }, IsVirtualItem = false, Recursive = true }); _logger.LogInformation("Audience: examining {Count} titles.", items.Count); var ledger = LedgerStore.Load(); var repaired = 0; var tagged = 0; var unmapped = new List(); var processed = 0; foreach (var item in items) { cancellationToken.ThrowIfCancellationRequested(); var changed = false; if (config.RepairRatings) { var remapped = AudienceRules.RemapRating(item.OfficialRating); if (remapped is not null) { _logger.LogInformation( "Audience: rating {Old} -> {New} on {Title}.", item.OfficialRating, remapped, item.Name); item.OfficialRating = remapped; // Lock it so a later provider refresh cannot reintroduce the // unparseable value. Nothing is forfeited: the provider had no // usable US rating for these titles in the first place. if (!item.LockedFields.Contains(MetadataField.OfficialRating)) { item.LockedFields = item.LockedFields .Append(MetadataField.OfficialRating) .Distinct() .ToArray(); } repaired++; changed = true; } else if (!AudienceRules.IsUnratedValue(item.OfficialRating) && ResolveScore(item.OfficialRating) is null) { // A rating that is neither recognised nor in the remap table. Report it // rather than guess: it is currently taking the unrated path and is // therefore visible to every profile. unmapped.Add($"{item.OfficialRating} ({item.Name})"); } } if (config.ApplyTags) { var hasTag = item.Tags.Contains(config.TagName, StringComparer.OrdinalIgnoreCase); var wants = AudienceRules.WantsTag(item, ResolveScore(item.OfficialRating), config); var seenBefore = ledger.Contains(item.Id); // An id in the ledger without the tag was untagged by a human on purpose. // Re-applying it would quietly undo their curation every single night. var suppressed = config.RespectManualUntag && seenBefore && !hasTag; if (wants && !hasTag && !suppressed) { item.Tags = item.Tags.Append(config.TagName).ToArray(); ledger.Add(item.Id); tagged++; changed = true; } } if (changed) { await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken) .ConfigureAwait(false); } processed++; progress.Report(processed * 100.0 / items.Count); } LedgerStore.Save(ledger); _logger.LogInformation( "Audience: repaired {Repaired} ratings, applied {Tagged} tags.", repaired, tagged); if (unmapped.Count > 0) { // Deliberately a warning. These are invisible to parental controls until a // human adds a mapping, so they must not scroll past as information. _logger.LogWarning( "Audience: {Count} rating value(s) are neither recognised by Jellyfin nor " + "in the remap table, so they resolve as unrated and stay visible to every " + "profile: {Values}", unmapped.Count, string.Join("; ", unmapped.Distinct())); } } /// /// Resolves a rating string to Jellyfin's numeric score, or null when it does not parse. /// private int? ResolveScore(string? officialRating) { if (string.IsNullOrWhiteSpace(officialRating)) { return null; } // Jellyfin 10.11 replaced the flat rating table (us.csv, string -> int) with a scored one // (us.json), and GetRatingLevel went with it. GetRatingScore returns a ParentalRatingScore // carrying Score plus SubScore; SubScore is what separates R (17/0) from NC-17 and TV-MA // (17/1). Only Score is needed here, because the tag rule is a plain ceiling. return _localization.GetRatingScore(officialRating)?.Score; } }