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));
}
}