using System; using System.Collections.Generic; using System.Linq; using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; namespace Jellyfin.Plugin.WatchedTogether.Services; /// /// The content restrictions on one user, in a form that can be compared and combined. /// /// /// /// Every field is read and written the way Jellyfin's BaseItem.IsParentalAllowed reads it, /// so "stricter" here means "hides at least everything the other hides" there: /// /// /// The rating cap allows an item whose score is below the cap, or equal to it with a /// sub-score no higher than the sub-cap (a null sub-cap allows any). A null cap allows everything. /// Each blocked unrated kind hides the unrated items of that kind. /// A blocked tag hides any item carrying it. /// A non-empty allowed-tag list hides any item carrying none of them. An empty list /// is not a whitelist at all: it allows everything through this route. /// /// public sealed record ContentRestrictions { /// /// The tag written as the whole allowed-tag list when the members' whitelists have nothing in /// common. No item carries it, so it hides everything - which an empty list would not, since /// Jellyfin reads an empty list as "no whitelist". /// public const string NothingAllowedTag = "watched-together:nothing"; /// /// Gets restrictions that hide everything. Used for a member that cannot be resolved, on the /// same principle as library access: an unknown member must not widen the group. /// public static ContentRestrictions FullyRestricted { get; } = new() { MaxParentalRatingScore = 0, MaxParentalRatingSubScore = 0, BlockUnratedItems = Enum.GetValues().ToHashSet(), BlockedTags = new HashSet(StringComparer.OrdinalIgnoreCase), AllowedTags = new HashSet(StringComparer.OrdinalIgnoreCase), }; /// /// Gets the parental rating cap, or null for no cap. /// public int? MaxParentalRatingScore { get; init; } /// /// Gets the sub-score cap that applies at exactly , or /// null for any sub-score. /// public int? MaxParentalRatingSubScore { get; init; } /// /// Gets the kinds of unrated item that are hidden. /// public IReadOnlySet BlockUnratedItems { get; init; } = new HashSet(); /// /// Gets the tags that hide an item. /// public IReadOnlySet BlockedTags { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); /// /// Gets the whitelist an item must match, null when there is no whitelist, or an empty /// set when the whitelist is in force but lets nothing through. /// public IReadOnlySet? AllowedTags { get; init; } /// /// Gets a value indicating whether a whitelist is in force. /// public bool HasAllowedTags => AllowedTags is not null; /// /// Reads a user's live restrictions. /// /// The user to read. /// The user's restrictions. public static ContentRestrictions FromUser(User user) { ArgumentNullException.ThrowIfNull(user); var allowed = CleanTags(user.GetPreference(PreferenceKind.AllowedTags)); return new ContentRestrictions { MaxParentalRatingScore = user.MaxParentalRatingScore, MaxParentalRatingSubScore = user.MaxParentalRatingSubScore, BlockUnratedItems = user.GetPreferenceValues(PreferenceKind.BlockUnratedItems).ToHashSet(), BlockedTags = CleanTags(user.GetPreference(PreferenceKind.BlockedTags)), AllowedTags = allowed.Count switch { 0 => null, // A whitelist consisting only of the sentinel is the stored form of "nothing". 1 when allowed.Contains(NothingAllowedTag) => new HashSet(StringComparer.OrdinalIgnoreCase), _ => allowed, }, }; } /// /// Combines two sets of restrictions, keeping the stricter value of each field. /// /// The restrictions to combine with. /// Restrictions at least as strict as both. public ContentRestrictions CombineStrictest(ContentRestrictions other) { ArgumentNullException.ThrowIfNull(other); var (score, subScore) = RatingCapIsAtMost(this, other) ? (MaxParentalRatingScore, MaxParentalRatingSubScore) : (other.MaxParentalRatingScore, other.MaxParentalRatingSubScore); var blockUnrated = new HashSet(BlockUnratedItems); blockUnrated.UnionWith(other.BlockUnratedItems); var blocked = new HashSet(BlockedTags, StringComparer.OrdinalIgnoreCase); blocked.UnionWith(other.BlockedTags); // Only members with a whitelist constrain; a member without one accepts the other's. IReadOnlySet? allowed; if (AllowedTags is null) { allowed = other.AllowedTags; } else if (other.AllowedTags is null) { allowed = AllowedTags; } else { var both = new HashSet(AllowedTags, StringComparer.OrdinalIgnoreCase); both.IntersectWith(other.AllowedTags); allowed = both; } return new ContentRestrictions { MaxParentalRatingScore = score, MaxParentalRatingSubScore = subScore, BlockUnratedItems = blockUnrated, BlockedTags = blocked, AllowedTags = allowed, }; } /// /// Determines whether these restrictions hide at least everything /// hides. /// /// The restrictions to compare against. /// true if every field here is at least as restrictive. public bool IsAtLeastAsStrictAs(ContentRestrictions other) { ArgumentNullException.ThrowIfNull(other); if (!RatingCapIsAtMost(this, other)) { return false; } if (!BlockUnratedItems.IsSupersetOf(other.BlockUnratedItems)) { return false; } if (!BlockedTags.IsSupersetOf(other.BlockedTags)) { return false; } // No whitelist on the other side constrains nothing. Otherwise ours must exist and let // through no more than theirs does. return other.AllowedTags is null || (AllowedTags is not null && AllowedTags.IsSubsetOf(other.AllowedTags)); } /// /// Gets the allowed-tag list in the form Jellyfin stores it: empty for no whitelist, the /// sentinel for a whitelist that allows nothing. /// /// The tags to write to the user's policy. public string[] AllowedTagsForPolicy() { if (AllowedTags is null) { return []; } return AllowedTags.Count == 0 ? [NothingAllowedTag] : AllowedTags.ToArray(); } /// /// Compares two rating caps: true when 's cap allows no more than /// 's. A null cap allows everything; at an equal score, a null sub-cap /// allows every sub-score. /// private static bool RatingCapIsAtMost(ContentRestrictions a, ContentRestrictions b) { if (b.MaxParentalRatingScore is null) { return true; } if (a.MaxParentalRatingScore is null) { return false; } if (a.MaxParentalRatingScore.Value != b.MaxParentalRatingScore.Value) { return a.MaxParentalRatingScore.Value < b.MaxParentalRatingScore.Value; } if (b.MaxParentalRatingSubScore is null) { return true; } return a.MaxParentalRatingSubScore is not null && a.MaxParentalRatingSubScore.Value <= b.MaxParentalRatingSubScore.Value; } private static HashSet CleanTags(IEnumerable tags) => tags.Where(t => !string.IsNullOrWhiteSpace(t)) .Select(t => t.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); }