using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Plugin.WatchedTogether.Configuration; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.WatchedTogether.Services; /// /// Gives a shared account its members' content restrictions - strictest wins, except for a rating /// cap the group has chosen - and refuses to let a member unlock an account looser than they are. /// /// /// The second half is what makes choosing a cap safe. A parent may raise the shared account's cap /// above the child's to watch something together; the unlock rule means the child's own password /// no longer opens that account, so they cannot use it to get around their cap alone. It also /// bounds the choice: past the loosest member's cap nobody could unlock the account at all. /// public class RestrictionService : IRestrictionService { private readonly IUserManager _userManager; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The user manager. /// The logger. public RestrictionService(IUserManager userManager, ILogger logger) { _userManager = userManager; _logger = logger; } /// public ContentRestrictions ComputeStrictest(IReadOnlyList memberIds) { ArgumentNullException.ThrowIfNull(memberIds); if (memberIds.Count == 0) { return ContentRestrictions.FullyRestricted; } ContentRestrictions? result = null; foreach (var memberId in memberIds) { var member = _userManager.GetUserById(memberId); if (member is null) { // Same rule as library access: an unknown member must not widen the group. _logger.LogWarning( "Member {MemberId} could not be resolved; treating its restrictions as total", memberId); return ContentRestrictions.FullyRestricted; } var own = ContentRestrictions.FromUser(member); result = result is null ? own : result.CombineStrictest(own); } return result!; } /// public RatingRange GetRatingRange(IReadOnlyList memberIds) { ArgumentNullException.ThrowIfNull(memberIds); int? strictest = null; int? loosest = null; var anyUncapped = false; foreach (var memberId in memberIds) { var member = _userManager.GetUserById(memberId); // An unknown member counts as fully capped, consistent with ComputeStrictest. var cap = member is null ? 0 : member.MaxParentalRatingScore; if (cap is null) { anyUncapped = true; continue; } strictest = strictest is null ? cap : Math.Min(strictest.Value, cap.Value); loosest = loosest is null ? cap : Math.Max(loosest.Value, cap.Value); } return new RatingRange(strictest, anyUncapped ? null : loosest); } /// public async Task ApplyAsync(SharedGroup group) { ArgumentNullException.ThrowIfNull(group); var restrictions = ComputeStrictest(group.MemberUserIds); var adjusted = ClampChosenCap(group); if (!group.InheritParentalRating) { // The chosen cap replaces only the rating; everything else stays strictest-wins. The // sub-score cap is dropped: the choice is a level, not a level-and-a-half. restrictions = restrictions with { MaxParentalRatingScore = group.ParentalRatingCap, MaxParentalRatingSubScore = null, }; } var sharedUser = _userManager.GetUserById(group.SharedUserId); if (sharedUser is null) { return new RestrictionApplyResult(restrictions, adjusted); } var policy = _userManager.GetUserDto(sharedUser).Policy; if (policy is null) { _logger.LogWarning( "Could not read the policy for shared account {SharedUserId}; restrictions unchanged", group.SharedUserId); return new RestrictionApplyResult(restrictions, adjusted); } policy.MaxParentalRating = restrictions.MaxParentalRatingScore; policy.MaxParentalSubRating = restrictions.MaxParentalRatingSubScore; policy.BlockUnratedItems = restrictions.BlockUnratedItems.ToArray(); policy.BlockedTags = restrictions.BlockedTags.ToArray(); policy.AllowedTags = restrictions.AllowedTagsForPolicy(); // A shared account is a union of other people's credentials; it must never carry a // privilege none of them individually hold. policy.IsAdministrator = false; await _userManager.UpdatePolicyAsync(group.SharedUserId, policy).ConfigureAwait(false); if (restrictions.HasAllowedTags && restrictions.AllowedTags!.Count == 0) { _logger.LogWarning( "Shared account {SharedUserId} can see nothing: its members' allowed-tag lists have nothing in common", group.SharedUserId); } else { _logger.LogInformation( "Shared account {SharedUserId} restricted to rating cap {Score}/{SubScore} ({Source}), {UnratedCount} unrated kinds blocked, {BlockedCount} blocked tags, {AllowedCount} allowed tags", group.SharedUserId, restrictions.MaxParentalRatingScore, restrictions.MaxParentalRatingSubScore, group.InheritParentalRating ? "strictest member" : "chosen", restrictions.BlockUnratedItems.Count, restrictions.BlockedTags.Count, restrictions.AllowedTags?.Count); } return new RestrictionApplyResult(restrictions, adjusted); } /// /// Keeps a group's chosen cap within its members' range, turning a choice that has become /// meaningless back into inheritance. /// /// The group to adjust in place. /// true if anything changed. private bool ClampChosenCap(SharedGroup group) { if (group.InheritParentalRating) { return false; } var range = GetRatingRange(group.MemberUserIds); // Nobody capped, or a choice at or below the strictest member: that is just inheriting. if (!range.HasChoice || (group.ParentalRatingCap is not null && group.ParentalRatingCap <= range.Strictest)) { _logger.LogInformation( "Group {SharedUserId}: chosen rating cap {Cap} is no looser than its strictest member; inheriting instead", group.SharedUserId, group.ParentalRatingCap); group.InheritParentalRating = true; group.ParentalRatingCap = null; return true; } // Looser than the loosest member: nobody could unlock the account. Pull it back to the // loosest member rather than leave a group nobody can log into. if (range.Loosest is not null && (group.ParentalRatingCap is null || group.ParentalRatingCap > range.Loosest)) { _logger.LogWarning( "Group {SharedUserId}: chosen rating cap {Cap} is looser than every member; lowered to {Loosest}", group.SharedUserId, group.ParentalRatingCap, range.Loosest); group.ParentalRatingCap = range.Loosest; return true; } return false; } /// public bool IsAtLeastAsStrict(User candidate, User member) { ArgumentNullException.ThrowIfNull(candidate); ArgumentNullException.ThrowIfNull(member); return ContentRestrictions.FromUser(candidate) .IsAtLeastAsStrictAs(ContentRestrictions.FromUser(member)); } }