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