A shared account previously inherited its members' library access but none of their content restrictions, so a child could log into "alice+kid" with their own password and get around their own rating cap. The shared account now gets the strictest member's parental rating, unrated-item block, blocked tags and allowed tags, recomputed at creation, on membership change and at startup. An admin can raise the rating cap on a slider between the strictest and the loosest member; unrated and tag rules stay strictest-wins. What makes raising the cap safe is the unlock rule: after a member's password matches, both users' live policies are compared and the login is refused if the account is looser than the member on any field. So raising the cap above the child's rating means the child's password no longer opens the account, while the parent's still does. The same rule bounds the slider - past the loosest member nobody could unlock the account - so a chosen cap is clamped back into range whenever applied. Allowed tags need care: Jellyfin reads an empty list as "no whitelist", so an empty intersection of members' whitelists is written as a sentinel tag no item carries. Access schedules and channels are not inherited yet. The shared account is never an administrator. Groups created at the login screen always inherit and are restricted before the first session exists. The dashboard shows each member's cap, who a chosen cap shuts out, and the restrictions in effect, and gains a per-group edit form for the sync options. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
237 lines
8.7 KiB
C#
237 lines
8.7 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The content restrictions on one user, in a form that can be compared and combined.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Every field is read and written the way Jellyfin's <c>BaseItem.IsParentalAllowed</c> reads it,
|
|
/// so "stricter" here means "hides at least everything the other hides" there:
|
|
/// </para>
|
|
/// <list type="bullet">
|
|
/// <item>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.</item>
|
|
/// <item>Each blocked unrated kind hides the unrated items of that kind.</item>
|
|
/// <item>A blocked tag hides any item carrying it.</item>
|
|
/// <item>A non-empty allowed-tag list hides any item carrying none of them. An <em>empty</em> list
|
|
/// is not a whitelist at all: it allows everything through this route.</item>
|
|
/// </list>
|
|
/// </remarks>
|
|
public sealed record ContentRestrictions
|
|
{
|
|
/// <summary>
|
|
/// 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".
|
|
/// </summary>
|
|
public const string NothingAllowedTag = "watched-together:nothing";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static ContentRestrictions FullyRestricted { get; } = new()
|
|
{
|
|
MaxParentalRatingScore = 0,
|
|
MaxParentalRatingSubScore = 0,
|
|
BlockUnratedItems = Enum.GetValues<UnratedItem>().ToHashSet(),
|
|
BlockedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase),
|
|
AllowedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Gets the parental rating cap, or <c>null</c> for no cap.
|
|
/// </summary>
|
|
public int? MaxParentalRatingScore { get; init; }
|
|
|
|
/// <summary>
|
|
/// Gets the sub-score cap that applies at exactly <see cref="MaxParentalRatingScore"/>, or
|
|
/// <c>null</c> for any sub-score.
|
|
/// </summary>
|
|
public int? MaxParentalRatingSubScore { get; init; }
|
|
|
|
/// <summary>
|
|
/// Gets the kinds of unrated item that are hidden.
|
|
/// </summary>
|
|
public IReadOnlySet<UnratedItem> BlockUnratedItems { get; init; } = new HashSet<UnratedItem>();
|
|
|
|
/// <summary>
|
|
/// Gets the tags that hide an item.
|
|
/// </summary>
|
|
public IReadOnlySet<string> BlockedTags { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>
|
|
/// Gets the whitelist an item must match, <c>null</c> when there is no whitelist, or an empty
|
|
/// set when the whitelist is in force but lets nothing through.
|
|
/// </summary>
|
|
public IReadOnlySet<string>? AllowedTags { get; init; }
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether a whitelist is in force.
|
|
/// </summary>
|
|
public bool HasAllowedTags => AllowedTags is not null;
|
|
|
|
/// <summary>
|
|
/// Reads a user's live restrictions.
|
|
/// </summary>
|
|
/// <param name="user">The user to read.</param>
|
|
/// <returns>The user's restrictions.</returns>
|
|
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<UnratedItem>(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<string>(StringComparer.OrdinalIgnoreCase),
|
|
_ => allowed,
|
|
},
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Combines two sets of restrictions, keeping the stricter value of each field.
|
|
/// </summary>
|
|
/// <param name="other">The restrictions to combine with.</param>
|
|
/// <returns>Restrictions at least as strict as both.</returns>
|
|
public ContentRestrictions CombineStrictest(ContentRestrictions other)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(other);
|
|
|
|
var (score, subScore) = RatingCapIsAtMost(this, other)
|
|
? (MaxParentalRatingScore, MaxParentalRatingSubScore)
|
|
: (other.MaxParentalRatingScore, other.MaxParentalRatingSubScore);
|
|
|
|
var blockUnrated = new HashSet<UnratedItem>(BlockUnratedItems);
|
|
blockUnrated.UnionWith(other.BlockUnratedItems);
|
|
|
|
var blocked = new HashSet<string>(BlockedTags, StringComparer.OrdinalIgnoreCase);
|
|
blocked.UnionWith(other.BlockedTags);
|
|
|
|
// Only members with a whitelist constrain; a member without one accepts the other's.
|
|
IReadOnlySet<string>? allowed;
|
|
if (AllowedTags is null)
|
|
{
|
|
allowed = other.AllowedTags;
|
|
}
|
|
else if (other.AllowedTags is null)
|
|
{
|
|
allowed = AllowedTags;
|
|
}
|
|
else
|
|
{
|
|
var both = new HashSet<string>(AllowedTags, StringComparer.OrdinalIgnoreCase);
|
|
both.IntersectWith(other.AllowedTags);
|
|
allowed = both;
|
|
}
|
|
|
|
return new ContentRestrictions
|
|
{
|
|
MaxParentalRatingScore = score,
|
|
MaxParentalRatingSubScore = subScore,
|
|
BlockUnratedItems = blockUnrated,
|
|
BlockedTags = blocked,
|
|
AllowedTags = allowed,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether these restrictions hide at least everything <paramref name="other"/>
|
|
/// hides.
|
|
/// </summary>
|
|
/// <param name="other">The restrictions to compare against.</param>
|
|
/// <returns><c>true</c> if every field here is at least as restrictive.</returns>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the allowed-tag list in the form Jellyfin stores it: empty for no whitelist, the
|
|
/// sentinel for a whitelist that allows nothing.
|
|
/// </summary>
|
|
/// <returns>The tags to write to the user's policy.</returns>
|
|
public string[] AllowedTagsForPolicy()
|
|
{
|
|
if (AllowedTags is null)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return AllowedTags.Count == 0 ? [NothingAllowedTag] : AllowedTags.ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compares two rating caps: true when <paramref name="a"/>'s cap allows no more than
|
|
/// <paramref name="b"/>'s. A null cap allows everything; at an equal score, a null sub-cap
|
|
/// allows every sub-score.
|
|
/// </summary>
|
|
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<string> CleanTags(IEnumerable<string> tags)
|
|
=> tags.Where(t => !string.IsNullOrWhiteSpace(t))
|
|
.Select(t => t.Trim())
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
}
|