Inherit parental restrictions on shared accounts, with a chosen rating cap
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>
This commit is contained in:
@@ -61,10 +61,15 @@ public class AuthenticationTests
|
||||
dynamic.Setup(d => d.TryCreateFromLoginAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(dynamicResult);
|
||||
|
||||
// Restrictions are covered by RestrictionTests; here every member is allowed through.
|
||||
var restrictions = new Mock<IRestrictionService>();
|
||||
restrictions.Setup(r => r.IsAtLeastAsStrict(It.IsAny<User>(), It.IsAny<User>())).Returns(true);
|
||||
|
||||
return new SharedAccountAuthenticationProvider(
|
||||
crypto,
|
||||
new Lazy<IGroupService>(() => groups.Object),
|
||||
new Lazy<IDynamicGroupService>(() => dynamic.Object),
|
||||
new Lazy<IRestrictionService>(() => restrictions.Object),
|
||||
NullLogger<SharedAccountAuthenticationProvider>.Instance);
|
||||
}
|
||||
|
||||
|
||||
@@ -88,9 +88,14 @@ public class DynamicGroupTests
|
||||
|
||||
var crypto = new StubCryptoProvider(validPairs);
|
||||
|
||||
// Restrictions are covered by RestrictionTests; here every member is allowed through.
|
||||
var restrictions = new Mock<IRestrictionService>();
|
||||
restrictions.Setup(r => r.IsAtLeastAsStrict(It.IsAny<User>(), It.IsAny<User>())).Returns(true);
|
||||
|
||||
var service = new DynamicGroupService(
|
||||
userManager.Object,
|
||||
provisioning.Object,
|
||||
restrictions.Object,
|
||||
crypto,
|
||||
NullLogger<DynamicGroupService>.Instance);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Plugin.WatchedTogether.Configuration;
|
||||
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -55,6 +56,7 @@ public class ProvisioningTests
|
||||
Mock.Of<MediaBrowser.Model.Cryptography.ICryptoProvider>(),
|
||||
new Lazy<IGroupService>(() => Mock.Of<IGroupService>()),
|
||||
new Lazy<IDynamicGroupService>(() => Mock.Of<IDynamicGroupService>()),
|
||||
new Lazy<IRestrictionService>(() => Mock.Of<IRestrictionService>()),
|
||||
NullLogger<Auth.SharedAccountAuthenticationProvider>.Instance)
|
||||
.ChangePassword(user, password);
|
||||
}
|
||||
@@ -74,10 +76,19 @@ public class ProvisioningTests
|
||||
}
|
||||
|
||||
private static ProvisioningService MakeService(Mock<IUserManager> userManager)
|
||||
=> new(
|
||||
{
|
||||
// Restrictions are covered by RestrictionTests; here applying them is a no-op that
|
||||
// reports nothing adjusted.
|
||||
var restrictions = new Mock<IRestrictionService>();
|
||||
restrictions.Setup(r => r.ApplyAsync(It.IsAny<SharedGroup>()))
|
||||
.ReturnsAsync(new RestrictionApplyResult(new ContentRestrictions(), false));
|
||||
|
||||
return new ProvisioningService(
|
||||
userManager.Object,
|
||||
Mock.Of<ILibraryAccessService>(),
|
||||
restrictions.Object,
|
||||
NullLogger<ProvisioningService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGroupAsync_SetsPasswordBeforeClaimingTheAccount()
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Plugin.WatchedTogether.Auth;
|
||||
using Jellyfin.Plugin.WatchedTogether.Configuration;
|
||||
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the rule that a shared account is at least as restricted as every member who can unlock
|
||||
/// it: how members' restrictions combine, and who an account with a chosen rating cap lets in.
|
||||
/// </summary>
|
||||
[Collection(nameof(PluginTestContext))]
|
||||
public class RestrictionTests
|
||||
{
|
||||
private const string AliceHash = "$PBKDF2-SHA512$iterations=210000$A1A1A1A1$AAAAAAAABBBBBBBB";
|
||||
private const string KidHash = "$PBKDF2-SHA512$iterations=210000$B2B2B2B2$CCCCCCCCDDDDDDDD";
|
||||
private const string TeenHash = "$PBKDF2-SHA512$iterations=210000$C3C3C3C3$EEEEEEEEFFFFFFFF";
|
||||
|
||||
private static User MakeUser(string name, string? password = null)
|
||||
=> new(name, "Prov", "ResetProv") { Password = password! };
|
||||
|
||||
/// <summary>
|
||||
/// A user manager that resolves the given users by id and name and round-trips policies.
|
||||
/// </summary>
|
||||
private static Mock<IUserManager> MakeUserManager(List<User> users)
|
||||
{
|
||||
var userManager = new Mock<IUserManager>();
|
||||
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>()))
|
||||
.Returns((Guid id) => users.Find(u => u.Id == id)!);
|
||||
userManager.Setup(m => m.GetUserByName(It.IsAny<string>()))
|
||||
.Returns((string n) => users.Find(
|
||||
u => string.Equals(u.Username, n, StringComparison.OrdinalIgnoreCase))!);
|
||||
userManager.Setup(m => m.CreateUserAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string name) =>
|
||||
{
|
||||
var created = MakeUser(name);
|
||||
users.Add(created);
|
||||
return created;
|
||||
});
|
||||
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>())).Returns(Task.CompletedTask);
|
||||
userManager.SetupChangePassword(users, (user, password) =>
|
||||
{
|
||||
user.Password = password;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
userManager.SetupPolicyRoundTrip(users);
|
||||
return userManager;
|
||||
}
|
||||
|
||||
private static RestrictionService MakeService(List<User> users)
|
||||
=> new(MakeUserManager(users).Object, NullLogger<RestrictionService>.Instance);
|
||||
|
||||
private sealed record Harness(
|
||||
SharedAccountAuthenticationProvider Provider,
|
||||
ProvisioningService Provisioning,
|
||||
List<User> Users);
|
||||
|
||||
/// <summary>
|
||||
/// Wires the real provisioning, group, dynamic-group, restriction and authentication services
|
||||
/// over a stub user manager, the same way <see cref="SharedAccountEndToEndTests"/> does.
|
||||
/// </summary>
|
||||
private static Harness MakeHarness(List<User> users, params (string Hash, string Password)[] validPairs)
|
||||
{
|
||||
var crypto = new StubCryptoProvider(validPairs);
|
||||
var userManager = MakeUserManager(users);
|
||||
|
||||
var groupService = new GroupService(userManager.Object, NullLogger<GroupService>.Instance);
|
||||
var restrictions = new RestrictionService(userManager.Object, NullLogger<RestrictionService>.Instance);
|
||||
var provisioning = new ProvisioningService(
|
||||
userManager.Object,
|
||||
Mock.Of<ILibraryAccessService>(),
|
||||
restrictions,
|
||||
NullLogger<ProvisioningService>.Instance);
|
||||
var dynamicGroups = new DynamicGroupService(
|
||||
userManager.Object,
|
||||
provisioning,
|
||||
restrictions,
|
||||
crypto,
|
||||
NullLogger<DynamicGroupService>.Instance);
|
||||
var provider = new SharedAccountAuthenticationProvider(
|
||||
crypto,
|
||||
new Lazy<IGroupService>(() => groupService),
|
||||
new Lazy<IDynamicGroupService>(() => dynamicGroups),
|
||||
new Lazy<IRestrictionService>(() => restrictions),
|
||||
NullLogger<SharedAccountAuthenticationProvider>.Instance);
|
||||
|
||||
return new Harness(provider, provisioning, users);
|
||||
}
|
||||
|
||||
// ---- combining members ----------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 13, 13)]
|
||||
[InlineData(13, null, 13)]
|
||||
[InlineData(7, 17, 7)]
|
||||
[InlineData(null, null, null)]
|
||||
public void Rating_StrictestScoreWins(int? a, int? b, int? expected)
|
||||
{
|
||||
var alice = MakeUser("alice").Restrict(maxRating: a);
|
||||
var bob = MakeUser("bob").Restrict(maxRating: b);
|
||||
|
||||
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
|
||||
|
||||
Assert.Equal(expected, result.MaxParentalRatingScore);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 2, 2)]
|
||||
[InlineData(3, 2, 2)]
|
||||
[InlineData(2, 3, 2)]
|
||||
[InlineData(null, null, null)]
|
||||
public void Rating_AtEqualScore_StrictestSubScoreWins(int? a, int? b, int? expected)
|
||||
{
|
||||
// At the same score a null sub-cap allows every sub-score, so any value beats it.
|
||||
var alice = MakeUser("alice").Restrict(maxRating: 13, maxSubRating: a);
|
||||
var bob = MakeUser("bob").Restrict(maxRating: 13, maxSubRating: b);
|
||||
|
||||
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
|
||||
|
||||
Assert.Equal(13, result.MaxParentalRatingScore);
|
||||
Assert.Equal(expected, result.MaxParentalRatingSubScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rating_LowerScore_WinsRegardlessOfSubScore()
|
||||
{
|
||||
var alice = MakeUser("alice").Restrict(maxRating: 13, maxSubRating: 0);
|
||||
var bob = MakeUser("bob").Restrict(maxRating: 7, maxSubRating: null);
|
||||
|
||||
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
|
||||
|
||||
Assert.Equal(7, result.MaxParentalRatingScore);
|
||||
Assert.Null(result.MaxParentalRatingSubScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnratedAndBlockedTags_AreUnioned()
|
||||
{
|
||||
var alice = MakeUser("alice").Restrict(blockUnrated: [UnratedItem.Movie], blockedTags: ["horror"]);
|
||||
var bob = MakeUser("bob").Restrict(blockUnrated: [UnratedItem.Series], blockedTags: ["Gore", "horror"]);
|
||||
|
||||
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
|
||||
|
||||
Assert.Equal(new HashSet<UnratedItem> { UnratedItem.Movie, UnratedItem.Series }, result.BlockUnratedItems);
|
||||
Assert.Equal(2, result.BlockedTags.Count);
|
||||
Assert.Contains("horror", result.BlockedTags);
|
||||
Assert.Contains("gore", result.BlockedTags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedTags_NoWhitelists_StaysNoWhitelist()
|
||||
{
|
||||
var alice = MakeUser("alice");
|
||||
var bob = MakeUser("bob");
|
||||
|
||||
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
|
||||
|
||||
Assert.False(result.HasAllowedTags);
|
||||
Assert.Empty(result.AllowedTagsForPolicy());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedTags_MemberWithoutWhitelist_AcceptsTheOthers()
|
||||
{
|
||||
// An empty allowed-tag list in Jellyfin is "no whitelist", not "allows nothing": it must
|
||||
// not wipe out the other member's whitelist.
|
||||
var alice = MakeUser("alice");
|
||||
var kid = MakeUser("kid").Restrict(allowedTags: ["kids", "family"]);
|
||||
|
||||
var result = MakeService([alice, kid]).ComputeStrictest([alice.Id, kid.Id]);
|
||||
|
||||
Assert.True(result.HasAllowedTags);
|
||||
Assert.Equal(new[] { "family", "kids" }, result.AllowedTagsForPolicy().OrderBy(t => t, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedTags_TwoWhitelists_Intersect()
|
||||
{
|
||||
var alice = MakeUser("alice").Restrict(allowedTags: ["kids", "family"]);
|
||||
var bob = MakeUser("bob").Restrict(allowedTags: ["Family", "documentary"]);
|
||||
|
||||
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
|
||||
|
||||
Assert.Equal(new[] { "family" }, result.AllowedTagsForPolicy());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedTags_DisjointWhitelists_AllowNothing_AndSurviveARoundTrip()
|
||||
{
|
||||
var alice = MakeUser("alice").Restrict(allowedTags: ["kids"]);
|
||||
var bob = MakeUser("bob").Restrict(allowedTags: ["documentary"]);
|
||||
|
||||
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
|
||||
|
||||
// In force, but empty. Jellyfin would read an empty list as unrestricted, so what gets
|
||||
// written is a tag no item carries...
|
||||
Assert.True(result.HasAllowedTags);
|
||||
Assert.Empty(result.AllowedTags!);
|
||||
Assert.Equal(new[] { ContentRestrictions.NothingAllowedTag }, result.AllowedTagsForPolicy());
|
||||
|
||||
// ...and reading a user carrying only that tag comes back as "allows nothing", not as a
|
||||
// one-tag whitelist, so the unlock rule treats it as stricter than anything.
|
||||
var shared = MakeUser("shared").Restrict(allowedTags: result.AllowedTagsForPolicy());
|
||||
var read = ContentRestrictions.FromUser(shared);
|
||||
Assert.True(read.HasAllowedTags);
|
||||
Assert.Empty(read.AllowedTags!);
|
||||
Assert.True(read.IsAtLeastAsStrictAs(ContentRestrictions.FromUser(alice)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnresolvableMember_YieldsFullyRestricted()
|
||||
{
|
||||
var alice = MakeUser("alice");
|
||||
|
||||
var result = MakeService([alice]).ComputeStrictest([alice.Id, Guid.NewGuid()]);
|
||||
|
||||
Assert.Equal(ContentRestrictions.FullyRestricted, result);
|
||||
Assert.True(result.IsAtLeastAsStrictAs(ContentRestrictions.FromUser(alice)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_WritesTheStrictestPolicy_AndNeverAdministrator()
|
||||
{
|
||||
var alice = MakeUser("alice").Restrict(blockedTags: ["horror"]);
|
||||
var kid = MakeUser("kid").Restrict(maxRating: 7, blockUnrated: [UnratedItem.Movie]);
|
||||
var shared = MakeUser("shared");
|
||||
shared.SetPermission(PermissionKind.IsAdministrator, true);
|
||||
|
||||
await MakeService([alice, kid, shared]).ApplyAsync(new SharedGroup
|
||||
{
|
||||
SharedUserId = shared.Id,
|
||||
MemberUserIds = [alice.Id, kid.Id]
|
||||
});
|
||||
|
||||
Assert.Equal(7, shared.MaxParentalRatingScore);
|
||||
Assert.Equal([UnratedItem.Movie], shared.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems));
|
||||
Assert.Equal(["horror"], shared.GetPreference(PreferenceKind.BlockedTags));
|
||||
Assert.False(shared.HasPermission(PermissionKind.IsAdministrator));
|
||||
}
|
||||
|
||||
// ---- the unlock rule ------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, true)]
|
||||
[InlineData(7, null, true)]
|
||||
[InlineData(7, 7, true)]
|
||||
[InlineData(7, 13, true)]
|
||||
[InlineData(13, 7, false)]
|
||||
[InlineData(null, 13, false)]
|
||||
public void IsAtLeastAsStrict_ComparesRatingCaps(int? shared, int? member, bool expected)
|
||||
{
|
||||
var sharedUser = MakeUser("shared").Restrict(maxRating: shared);
|
||||
var memberUser = MakeUser("member").Restrict(maxRating: member);
|
||||
|
||||
Assert.Equal(expected, MakeService([]).IsAtLeastAsStrict(sharedUser, memberUser));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAtLeastAsStrict_RequiresEverySetToBeCovered()
|
||||
{
|
||||
var service = MakeService([]);
|
||||
var member = MakeUser("member").Restrict(
|
||||
blockUnrated: [UnratedItem.Movie],
|
||||
blockedTags: ["horror"],
|
||||
allowedTags: ["kids", "family"]);
|
||||
|
||||
Assert.True(service.IsAtLeastAsStrict(
|
||||
MakeUser("s").Restrict(blockUnrated: [UnratedItem.Movie, UnratedItem.Series], blockedTags: ["horror", "gore"], allowedTags: ["kids"]),
|
||||
member));
|
||||
Assert.False(service.IsAtLeastAsStrict(
|
||||
MakeUser("s").Restrict(blockedTags: ["horror"], allowedTags: ["kids"]),
|
||||
member));
|
||||
Assert.False(service.IsAtLeastAsStrict(
|
||||
MakeUser("s").Restrict(blockUnrated: [UnratedItem.Movie], allowedTags: ["kids"]),
|
||||
member));
|
||||
Assert.False(service.IsAtLeastAsStrict(
|
||||
MakeUser("s").Restrict(blockUnrated: [UnratedItem.Movie], blockedTags: ["horror"], allowedTags: ["kids", "family", "sport"]),
|
||||
member));
|
||||
|
||||
// No whitelist on the shared side is looser than any whitelist on the member's.
|
||||
Assert.False(service.IsAtLeastAsStrict(
|
||||
MakeUser("s").Restrict(blockUnrated: [UnratedItem.Movie], blockedTags: ["horror"]),
|
||||
member));
|
||||
}
|
||||
|
||||
// ---- the rating range ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RatingRange_SpansStrictestToLoosest()
|
||||
{
|
||||
var alice = MakeUser("alice");
|
||||
var teen = MakeUser("teen").Restrict(maxRating: 13);
|
||||
var kid = MakeUser("kid").Restrict(maxRating: 7);
|
||||
var service = MakeService([alice, teen, kid]);
|
||||
|
||||
Assert.Equal(new RatingRange(7, 13), service.GetRatingRange([teen.Id, kid.Id]));
|
||||
Assert.Equal(new RatingRange(7, null), service.GetRatingRange([alice.Id, teen.Id, kid.Id]));
|
||||
Assert.Equal(new RatingRange(null, null), service.GetRatingRange([alice.Id]));
|
||||
Assert.False(service.GetRatingRange([alice.Id]).HasChoice);
|
||||
Assert.Equal(new RatingRange(0, 13), service.GetRatingRange([teen.Id, Guid.NewGuid()]));
|
||||
}
|
||||
|
||||
// ---- choosing a cap -----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ChosenCap_ShutsOutStricterMembers_AndLetsTheRestIn()
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var teen = MakeUser("teen", TeenHash).Restrict(maxRating: 13);
|
||||
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7);
|
||||
var h = MakeHarness([alice, teen, kid], (AliceHash, "alice-pw"), (TeenHash, "teen-pw"), (KidHash, "kid-pw"));
|
||||
|
||||
var group = await h.Provisioning.CreateGroupAsync([alice.Id, teen.Id, kid.Id], null);
|
||||
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
|
||||
|
||||
// Inherited: the account carries the child's cap and everyone gets in.
|
||||
Assert.Equal(7, shared.MaxParentalRatingScore);
|
||||
await h.Provider.Authenticate(shared.Username, "kid-pw", shared);
|
||||
|
||||
// Raised to the teen's level: the parent and the teen still unlock it, the child does not.
|
||||
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, teen.Id, kid.Id], true, false, false, false, 13);
|
||||
Assert.Equal(13, shared.MaxParentalRatingScore);
|
||||
await h.Provider.Authenticate(shared.Username, "alice-pw", shared);
|
||||
await h.Provider.Authenticate(shared.Username, "teen-pw", shared);
|
||||
await Assert.ThrowsAsync<AuthenticationException>(
|
||||
() => h.Provider.Authenticate(shared.Username, "kid-pw", shared));
|
||||
|
||||
// No cap at all: only the parent.
|
||||
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, teen.Id, kid.Id], true, false, false, false, null);
|
||||
Assert.Null(shared.MaxParentalRatingScore);
|
||||
await h.Provider.Authenticate(shared.Username, "alice-pw", shared);
|
||||
await Assert.ThrowsAsync<AuthenticationException>(
|
||||
() => h.Provider.Authenticate(shared.Username, "teen-pw", shared));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChosenCap_LeavesEverythingElseStrictest()
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice", AliceHash).Restrict(blockedTags: ["horror"]);
|
||||
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7, maxSubRating: 1, blockUnrated: [UnratedItem.Movie]);
|
||||
var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw"));
|
||||
|
||||
var group = await h.Provisioning.CreateGroupAsync([alice.Id, kid.Id], null);
|
||||
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
|
||||
|
||||
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, kid.Id], true, false, false, false, null);
|
||||
|
||||
Assert.Null(shared.MaxParentalRatingScore);
|
||||
Assert.Null(shared.MaxParentalRatingSubScore);
|
||||
Assert.Equal([UnratedItem.Movie], shared.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems));
|
||||
Assert.Equal(["horror"], shared.GetPreference(PreferenceKind.BlockedTags));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChosenCap_AboveTheLoosestMember_IsPulledBack()
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
|
||||
var teen = MakeUser("teen", TeenHash).Restrict(maxRating: 13);
|
||||
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7);
|
||||
var h = MakeHarness([teen, kid], (TeenHash, "teen-pw"), (KidHash, "kid-pw"));
|
||||
|
||||
var group = await h.Provisioning.CreateGroupAsync([teen.Id, kid.Id], null);
|
||||
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
|
||||
|
||||
// "No cap" past a group where everyone is capped would leave nobody able to unlock it.
|
||||
var updated = await h.Provisioning.UpdateGroupAsync(shared.Id, [teen.Id, kid.Id], true, false, false, false, null);
|
||||
|
||||
Assert.False(updated.InheritParentalRating);
|
||||
Assert.Equal(13, updated.ParentalRatingCap);
|
||||
Assert.Equal(13, Assert.Single(ctx.Configuration.Groups).ParentalRatingCap);
|
||||
Assert.Equal(13, shared.MaxParentalRatingScore);
|
||||
await h.Provider.Authenticate(shared.Username, "teen-pw", shared);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(7)]
|
||||
[InlineData(3)]
|
||||
public async Task ChosenCap_AtOrBelowTheStrictestMember_IsJustInheriting(int cap)
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7, maxSubRating: 2);
|
||||
var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw"));
|
||||
|
||||
var group = await h.Provisioning.CreateGroupAsync([alice.Id, kid.Id], null);
|
||||
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
|
||||
|
||||
var updated = await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, kid.Id], true, false, false, false, cap);
|
||||
|
||||
Assert.True(updated.InheritParentalRating);
|
||||
Assert.Null(updated.ParentalRatingCap);
|
||||
// Inheriting keeps the strictest member's sub-score too.
|
||||
Assert.Equal(7, shared.MaxParentalRatingScore);
|
||||
Assert.Equal(2, shared.MaxParentalRatingSubScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChosenCap_WhenNoMemberIsCapped_IsJustInheriting()
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var bob = MakeUser("bob", KidHash);
|
||||
var h = MakeHarness([alice, bob]);
|
||||
|
||||
var group = await h.Provisioning.CreateGroupAsync([alice.Id, bob.Id], null);
|
||||
var updated = await h.Provisioning.UpdateGroupAsync(group.SharedUserId, [alice.Id, bob.Id], true, false, false, false, null);
|
||||
|
||||
Assert.True(updated.InheritParentalRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChosenCap_IsReclampedWhenMembershipChanges()
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var teen = MakeUser("teen", TeenHash).Restrict(maxRating: 13);
|
||||
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7);
|
||||
var h = MakeHarness([alice, teen, kid], (AliceHash, "alice-pw"), (TeenHash, "teen-pw"), (KidHash, "kid-pw"));
|
||||
|
||||
var group = await h.Provisioning.CreateGroupAsync([alice.Id, teen.Id, kid.Id], null);
|
||||
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
|
||||
|
||||
// "No cap" is valid while the uncapped parent is a member...
|
||||
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, teen.Id, kid.Id], true, false, false, false, null);
|
||||
Assert.Null(shared.MaxParentalRatingScore);
|
||||
|
||||
// ...and is pulled back to the teen's level once the parent leaves, so the teen can still
|
||||
// unlock the account.
|
||||
var updated = await h.Provisioning.UpdateGroupAsync(shared.Id, [teen.Id, kid.Id], true, false, false, false, null);
|
||||
Assert.Equal(13, updated.ParentalRatingCap);
|
||||
Assert.Equal(13, shared.MaxParentalRatingScore);
|
||||
await h.Provider.Authenticate(shared.Username, "teen-pw", shared);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InheritedGroup_MemberCapLoweredAfterLastReapply_IsRefusedUntilRecomputed()
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 13);
|
||||
var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw"));
|
||||
|
||||
var group = await h.Provisioning.CreateGroupAsync([alice.Id, kid.Id], null);
|
||||
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
|
||||
Assert.Equal(13, shared.MaxParentalRatingScore);
|
||||
|
||||
// The drift case: the child's cap is lowered in the user editor, nothing recomputes the
|
||||
// shared account, and the unlock rule still holds because it reads both users live.
|
||||
kid.Restrict(maxRating: 7);
|
||||
await Assert.ThrowsAsync<AuthenticationException>(
|
||||
() => h.Provider.Authenticate(shared.Username, "kid-pw", shared));
|
||||
|
||||
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, kid.Id], true, false, false, true, null);
|
||||
Assert.Equal(7, shared.MaxParentalRatingScore);
|
||||
await h.Provider.Authenticate(shared.Username, "kid-pw", shared);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DynamicCreation_IsInherited_AndRestrictedBeforeTheFirstLoginCompletes()
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7, blockUnrated: [UnratedItem.Movie]);
|
||||
var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw"));
|
||||
|
||||
// The child types both names with their own password on a fresh server. The session this
|
||||
// creates must already be capped.
|
||||
var created = await h.Provider.Authenticate("alice+kid", "kid-pw", null);
|
||||
|
||||
var shared = h.Users.Find(u => u.Username == created.Username)!;
|
||||
Assert.Equal(7, shared.MaxParentalRatingScore);
|
||||
Assert.Equal([UnratedItem.Movie], shared.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems));
|
||||
Assert.True(Assert.Single(ctx.Configuration.Groups).InheritParentalRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DynamicLogin_ToAnExistingGroupWithAChosenCap_AppliesTheUnlockRule()
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7);
|
||||
var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw"));
|
||||
|
||||
var group = await h.Provisioning.CreateGroupAsync([alice.Id, kid.Id], "family");
|
||||
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
|
||||
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, kid.Id], true, false, false, false, null);
|
||||
|
||||
// "kid+alice" matches no account by name, so it reaches the dynamic path and resolves to
|
||||
// the existing group; the same rule must apply there.
|
||||
var asAlice = await h.Provider.Authenticate("kid+alice", "alice-pw", null);
|
||||
Assert.Equal("family", asAlice.Username);
|
||||
|
||||
await Assert.ThrowsAsync<AuthenticationException>(
|
||||
() => h.Provider.Authenticate("kid+alice", "kid-pw", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provisioning_SharedAccountIsNeverAnAdministrator()
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var bob = MakeUser("bob", KidHash);
|
||||
alice.SetPermission(PermissionKind.IsAdministrator, true);
|
||||
bob.SetPermission(PermissionKind.IsAdministrator, true);
|
||||
var h = MakeHarness([alice, bob]);
|
||||
|
||||
var group = await h.Provisioning.CreateGroupAsync([alice.Id, bob.Id], null);
|
||||
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
|
||||
Assert.False(shared.HasPermission(PermissionKind.IsAdministrator));
|
||||
|
||||
// Not even if granted afterwards.
|
||||
shared.SetPermission(PermissionKind.IsAdministrator, true);
|
||||
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, bob.Id], true, false, false, true, null);
|
||||
Assert.False(shared.HasPermission(PermissionKind.IsAdministrator));
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ public class SharedAccountEndToEndTests
|
||||
});
|
||||
|
||||
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>())).Returns(Task.CompletedTask);
|
||||
userManager.SetupPolicyRoundTrip(users);
|
||||
|
||||
userManager.SetupChangePassword(users, (user, password) =>
|
||||
{
|
||||
@@ -81,14 +82,22 @@ public class SharedAccountEndToEndTests
|
||||
userManager.Object,
|
||||
NullLogger<GroupService>.Instance);
|
||||
|
||||
// The real restriction service, over the same user manager: the unlock rule reads users
|
||||
// live, and this suite is about the real path.
|
||||
var restrictions = new RestrictionService(
|
||||
userManager.Object,
|
||||
NullLogger<RestrictionService>.Instance);
|
||||
|
||||
var provisioning = new ProvisioningService(
|
||||
userManager.Object,
|
||||
Mock.Of<ILibraryAccessService>(),
|
||||
restrictions,
|
||||
NullLogger<ProvisioningService>.Instance);
|
||||
|
||||
var dynamicGroups = new DynamicGroupService(
|
||||
userManager.Object,
|
||||
provisioning,
|
||||
restrictions,
|
||||
crypto,
|
||||
NullLogger<DynamicGroupService>.Instance);
|
||||
|
||||
@@ -96,6 +105,7 @@ public class SharedAccountEndToEndTests
|
||||
crypto,
|
||||
new Lazy<IGroupService>(() => groupService),
|
||||
new Lazy<IDynamicGroupService>(() => dynamicGroups),
|
||||
new Lazy<IRestrictionService>(() => restrictions),
|
||||
NullLogger<SharedAccountAuthenticationProvider>.Instance);
|
||||
|
||||
return new Harness(provider, users);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Users;
|
||||
using Moq;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
||||
@@ -38,4 +44,76 @@ internal static class UserManagerMockExtensions
|
||||
.Returns((User user, string password) => onChange(user, password));
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Models the policy round trip the way Jellyfin's UserManager does it: <c>GetUserDto</c>
|
||||
/// projects the user's live fields into a <see cref="UserPolicy"/>, and
|
||||
/// <c>UpdatePolicyAsync</c> writes a policy back onto the user entity, so code that reads the
|
||||
/// user afterwards sees what was written.
|
||||
/// </summary>
|
||||
/// <param name="mock">The user manager mock.</param>
|
||||
/// <param name="users">The users the mock knows about.</param>
|
||||
public static void SetupPolicyRoundTrip(this Mock<IUserManager> mock, List<User> users)
|
||||
{
|
||||
mock.Setup(m => m.GetUserDto(It.IsAny<User>(), It.IsAny<string?>()))
|
||||
.Returns((User user, string? _) => new UserDto
|
||||
{
|
||||
Id = user.Id,
|
||||
Name = user.Username,
|
||||
Policy = new UserPolicy
|
||||
{
|
||||
IsAdministrator = user.HasPermission(PermissionKind.IsAdministrator),
|
||||
EnableAllFolders = user.HasPermission(PermissionKind.EnableAllFolders),
|
||||
EnabledFolders = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders),
|
||||
MaxParentalRating = user.MaxParentalRatingScore,
|
||||
MaxParentalSubRating = user.MaxParentalRatingSubScore,
|
||||
BlockUnratedItems = user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems),
|
||||
BlockedTags = user.GetPreference(PreferenceKind.BlockedTags),
|
||||
AllowedTags = user.GetPreference(PreferenceKind.AllowedTags),
|
||||
}
|
||||
});
|
||||
|
||||
mock.Setup(m => m.UpdatePolicyAsync(It.IsAny<Guid>(), It.IsAny<UserPolicy>()))
|
||||
.Returns((Guid id, UserPolicy policy) =>
|
||||
{
|
||||
var user = users.Find(u => u.Id == id)
|
||||
?? throw new KeyNotFoundException($"No user with id {id}");
|
||||
|
||||
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
|
||||
user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
|
||||
user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders ?? []);
|
||||
user.MaxParentalRatingScore = policy.MaxParentalRating;
|
||||
user.MaxParentalRatingSubScore = policy.MaxParentalSubRating;
|
||||
user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? []);
|
||||
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags ?? []);
|
||||
user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags ?? []);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a user's content restrictions directly, the way the user editor would.
|
||||
/// </summary>
|
||||
/// <param name="user">The user to restrict.</param>
|
||||
/// <param name="maxRating">The rating cap, or <c>null</c> for none.</param>
|
||||
/// <param name="maxSubRating">The sub-score cap at the rating cap.</param>
|
||||
/// <param name="blockUnrated">The unrated kinds to block.</param>
|
||||
/// <param name="blockedTags">The tags that hide an item.</param>
|
||||
/// <param name="allowedTags">The whitelist, or none for no whitelist.</param>
|
||||
/// <returns>The same user, for chaining.</returns>
|
||||
public static User Restrict(
|
||||
this User user,
|
||||
int? maxRating = null,
|
||||
int? maxSubRating = null,
|
||||
IEnumerable<UnratedItem>? blockUnrated = null,
|
||||
IEnumerable<string>? blockedTags = null,
|
||||
IEnumerable<string>? allowedTags = null)
|
||||
{
|
||||
user.MaxParentalRatingScore = maxRating;
|
||||
user.MaxParentalRatingSubScore = maxSubRating;
|
||||
user.SetPreference(PreferenceKind.BlockUnratedItems, (blockUnrated ?? []).ToArray());
|
||||
user.SetPreference(PreferenceKind.BlockedTags, (blockedTags ?? []).ToArray());
|
||||
user.SetPreference(PreferenceKind.AllowedTags, (allowedTags ?? []).ToArray());
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,20 @@ namespace Jellyfin.Plugin.WatchedTogether.Auth;
|
||||
/// matched, eventually locking out members who did nothing wrong. Reading the live hash also means
|
||||
/// member password changes take effect immediately, with no second copy of any credential stored.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A matching password is not the whole story: the member may only unlock an account that is at
|
||||
/// least as restricted as they are. That is checked here against both users' <em>live</em>
|
||||
/// policies, so it holds even when the shared account's stored policy has drifted from its
|
||||
/// members', and it is what makes choosing a cap safe - the child's password stops opening an
|
||||
/// account the parent raised above the child's rating.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser
|
||||
{
|
||||
private readonly ICryptoProvider _cryptoProvider;
|
||||
private readonly Lazy<Services.IGroupService> _groupService;
|
||||
private readonly Lazy<Services.IDynamicGroupService> _dynamicGroupService;
|
||||
private readonly Lazy<Services.IRestrictionService> _restrictionService;
|
||||
private readonly ILogger<SharedAccountAuthenticationProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -38,6 +46,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
|
||||
/// <param name="cryptoProvider">The crypto provider used to verify stored password hashes.</param>
|
||||
/// <param name="groupService">A deferred handle to the group service.</param>
|
||||
/// <param name="dynamicGroupService">A deferred handle to the on-demand group creation service.</param>
|
||||
/// <param name="restrictionService">A deferred handle to the content restriction service.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <remarks>
|
||||
/// The group services are taken as <see cref="Lazy{T}"/> to break a container-level cycle.
|
||||
@@ -50,11 +59,13 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
|
||||
ICryptoProvider cryptoProvider,
|
||||
Lazy<Services.IGroupService> groupService,
|
||||
Lazy<Services.IDynamicGroupService> dynamicGroupService,
|
||||
Lazy<Services.IRestrictionService> restrictionService,
|
||||
ILogger<SharedAccountAuthenticationProvider> logger)
|
||||
{
|
||||
_cryptoProvider = cryptoProvider;
|
||||
_groupService = groupService;
|
||||
_dynamicGroupService = dynamicGroupService;
|
||||
_restrictionService = restrictionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -119,6 +130,17 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
|
||||
continue;
|
||||
}
|
||||
|
||||
// Information, not Warning: a child trying their password on the family account after
|
||||
// the parent raised its cap is expected, not an incident.
|
||||
if (!_restrictionService.Value.IsAtLeastAsStrict(resolvedUser, member))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Rejected login for {Username} by member {MemberUsername}: the shared account is less restricted than the member",
|
||||
resolvedUser.Username,
|
||||
member.Username);
|
||||
throw new AuthenticationException("Invalid username or password.");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Shared account {SharedUsername} unlocked by member {MemberUsername}",
|
||||
resolvedUser.Username,
|
||||
|
||||
@@ -41,6 +41,30 @@ public class SharedGroup
|
||||
/// </summary>
|
||||
public bool SyncPlayCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the shared account's parental rating cap is the
|
||||
/// strictest member's. When false, <see cref="ParentalRatingCap"/> is used instead.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Groups created at the login screen always inherit; choosing a cap is a dashboard-only
|
||||
/// action so nobody can widen access from the login screen. Unrated-item blocks and tag rules
|
||||
/// are always the strictest member's - they have no meaningful "level" to choose.
|
||||
/// </remarks>
|
||||
public bool InheritParentalRating { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the parental rating cap, as Jellyfin's numeric score, to use when
|
||||
/// <see cref="InheritParentalRating"/> is false. <c>null</c> means no cap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Kept within the range spanned by the members' own caps whenever it is applied: no lower than
|
||||
/// the strictest member (that would just be inheriting) and no looser than the loosest member
|
||||
/// (nobody could unlock the account past that, since a member may only unlock an account at
|
||||
/// least as restricted as they are). Wherever it sits in that range, members stricter than it
|
||||
/// can no longer unlock the account - that is the whole point of choosing one.
|
||||
/// </remarks>
|
||||
public int? ParentalRatingCap { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this group is suspended. A group drops out of both
|
||||
/// authentication and sync while disabled - set automatically if it falls below two members.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="WatchedTogetherConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
||||
data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
||||
data-require="emby-input,emby-button,emby-select,emby-checkbox,emby-slider">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
|
||||
@@ -47,8 +47,11 @@
|
||||
|
||||
<div class="fieldDescription" style="margin:1em 0">
|
||||
The shared account is granted only the libraries <em>every</em> member can
|
||||
already reach. If one member is blocked from a library, the group cannot see
|
||||
it either, so sharing an account never grants anyone new access.
|
||||
already reach, and inherits the strictest member's parental rating, unrated
|
||||
block and tag rules. If one member is blocked from something, the group cannot
|
||||
see it either, so sharing an account never grants anyone new access. To let a
|
||||
group watch above a member's rating, raise its cap with the slider afterwards:
|
||||
members stricter than the chosen cap then can no longer unlock the account.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -113,6 +116,141 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Jellyfin's parental rating levels for this server, one entry per distinct score,
|
||||
// ascending. Several names can share a score (e.g. "PG-13" and "TV-14").
|
||||
var ratingLevels = [];
|
||||
|
||||
function loadRatingLevels() {
|
||||
return ApiClient.getParentalRatings().then(function (ratings) {
|
||||
var byValue = {};
|
||||
ratings.forEach(function (r) {
|
||||
if (r.Value === null || r.Value === undefined) { return; }
|
||||
byValue[r.Value] = byValue[r.Value] || [];
|
||||
byValue[r.Value].push(r.Name);
|
||||
});
|
||||
ratingLevels = Object.keys(byValue).map(function (v) {
|
||||
return { value: Number(v), label: byValue[v].join(' / ') };
|
||||
}).sort(function (a, b) { return a.value - b.value; });
|
||||
}, function () {
|
||||
// Names are cosmetic; scores still display without them.
|
||||
});
|
||||
}
|
||||
|
||||
function ratingLabel(score) {
|
||||
if (score === null || score === undefined) { return 'No cap'; }
|
||||
var level = ratingLevels.filter(function (l) { return l.value === score; })[0];
|
||||
return level ? level.label : 'score ' + score;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
// The positions the cap slider can take: every rating level from the strictest member
|
||||
// up to the loosest, plus "No cap" when some member has none. Null when no member is
|
||||
// capped, since there is then nothing to choose.
|
||||
function capStops(g) {
|
||||
if (g.StrictestMemberRating === null || g.StrictestMemberRating === undefined) {
|
||||
return null;
|
||||
}
|
||||
var lo = g.StrictestMemberRating;
|
||||
var hi = g.LoosestMemberRating;
|
||||
var stops = ratingLevels.filter(function (l) {
|
||||
return l.value >= lo && (hi === null || hi === undefined || l.value <= hi);
|
||||
}).map(function (l) { return { value: l.value, label: l.label }; });
|
||||
if (!stops.length || stops[0].value !== lo) {
|
||||
stops.unshift({ value: lo, label: ratingLabel(lo) });
|
||||
}
|
||||
if (hi === null || hi === undefined) {
|
||||
stops.push({ value: null, label: 'No cap' });
|
||||
} else if (stops[stops.length - 1].value !== hi) {
|
||||
stops.push({ value: hi, label: ratingLabel(hi) });
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
function currentStop(g, stops) {
|
||||
if (g.InheritParentalRating) { return 0; }
|
||||
for (var i = 0; i < stops.length; i++) {
|
||||
if (stops[i].value === g.ParentalRatingCap) { return i; }
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Who the account still unlocks for at a given cap: members no stricter than it.
|
||||
function describeCap(g, stop, index) {
|
||||
var can = [], cannot = [];
|
||||
g.Members.forEach(function (m) {
|
||||
var own = m.MaxParentalRating;
|
||||
var ok = own === null || own === undefined || (stop.value !== null && own >= stop.value);
|
||||
(ok ? can : cannot).push(escapeHtml(m.Username));
|
||||
});
|
||||
var text = '<strong>' + escapeHtml(stop.label) + '</strong>';
|
||||
if (index === 0) {
|
||||
text += ' — inherited from the strictest member. Every member can unlock the account.';
|
||||
} else {
|
||||
text += ' — unlocks for ' + can.join(', ') + '.';
|
||||
if (cannot.length) {
|
||||
text += ' <span style="opacity:.8">' + cannot.join(', ') +
|
||||
(cannot.length === 1 ? '\'s password no longer opens' : ' can no longer open') +
|
||||
' this account.</span>';
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function describeRestrictions(r) {
|
||||
if (!r) { return 'Shared account not found.'; }
|
||||
var parts = [];
|
||||
if (r.BlockUnratedItems.length) {
|
||||
parts.push('Unrated blocked: ' + escapeHtml(r.BlockUnratedItems.join(', ')));
|
||||
}
|
||||
if (r.BlockedTags.length) {
|
||||
parts.push('Blocked tags: ' + escapeHtml(r.BlockedTags.join(', ')));
|
||||
}
|
||||
if (r.AllowsNothing) {
|
||||
parts.push('<strong>Allowed tags have nothing in common: the account can see nothing</strong>');
|
||||
} else if (r.AllowedTags.length) {
|
||||
parts.push('Allowed tags: ' + escapeHtml(r.AllowedTags.join(', ')));
|
||||
}
|
||||
return parts.length ? parts.join(' · ') : 'No unrated or tag rules from members.';
|
||||
}
|
||||
|
||||
function updateGroup(group, changes, onDone) {
|
||||
Dashboard.showLoadingMsg();
|
||||
var body = {
|
||||
MemberUserIds: group.Members.map(function (m) { return m.UserId; }),
|
||||
SyncUnwatched: group.SyncUnwatched,
|
||||
SyncPlayCount: group.SyncPlayCount,
|
||||
IsDisabled: group.IsDisabled,
|
||||
InheritParentalRating: group.InheritParentalRating,
|
||||
ParentalRatingCap: group.ParentalRatingCap
|
||||
};
|
||||
Object.keys(changes).forEach(function (k) { body[k] = changes[k]; });
|
||||
ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: apiUrl('Groups/' + group.SharedUserId),
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(body)
|
||||
}).then(function () {
|
||||
Dashboard.hideLoadingMsg();
|
||||
if (onDone) { onDone(); }
|
||||
loadGroups();
|
||||
}, function (response) {
|
||||
Dashboard.hideLoadingMsg();
|
||||
if (response && response.text) {
|
||||
response.text().then(function (msg) {
|
||||
Dashboard.alert({ title: 'Could not update group', message: msg });
|
||||
});
|
||||
} else {
|
||||
Dashboard.alert('Could not update group.');
|
||||
}
|
||||
loadGroups();
|
||||
});
|
||||
}
|
||||
|
||||
function renderGroups(groups) {
|
||||
var container = page.querySelector('#groupsList');
|
||||
|
||||
@@ -122,20 +260,108 @@
|
||||
}
|
||||
|
||||
container.innerHTML = groups.map(function (g) {
|
||||
var members = g.Members.map(function (m) { return m.Username; }).join(', ');
|
||||
var members = g.Members.map(function (m) {
|
||||
var own = m.MaxParentalRating;
|
||||
var cap = (own === null || own === undefined) ? 'no cap' : ratingLabel(own);
|
||||
return escapeHtml(m.Username) + ' <span style="opacity:.7">(' + escapeHtml(cap) + ')</span>';
|
||||
}).join(', ');
|
||||
var status = g.IsDisabled ? ' <span style="opacity:.7">(disabled)</span>' : '';
|
||||
var stops = capStops(g);
|
||||
var capHtml;
|
||||
if (!stops) {
|
||||
capHtml = '<div class="fieldDescription">Parental rating: no member has a cap, so there is nothing to choose.</div>';
|
||||
} else {
|
||||
var idx = currentStop(g, stops);
|
||||
capHtml = '<div class="sliderContainer-settings" style="margin-top:.6em">' +
|
||||
'<label class="sliderLabel">Parental rating cap</label>' +
|
||||
'<div style="display:flex;align-items:center;gap:.8em">' +
|
||||
'<span class="fieldDescription" style="white-space:nowrap">' + escapeHtml(stops[0].label) + '</span>' +
|
||||
'<input type="range" is="emby-slider" class="ratingCapSlider" data-id="' + g.SharedUserId + '" ' +
|
||||
'min="0" max="' + (stops.length - 1) + '" step="1" value="' + idx + '" style="flex:1" />' +
|
||||
'<span class="fieldDescription" style="white-space:nowrap">' + escapeHtml(stops[stops.length - 1].label) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="fieldDescription ratingCapText" data-id="' + g.SharedUserId + '">' + describeCap(g, stops[idx], idx) + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
return '<div class="listItem" style="padding:.6em 0;border-bottom:1px solid rgba(255,255,255,.1)">' +
|
||||
'<h3 style="margin:0">' + g.SharedUsername + status + '</h3>' +
|
||||
'<h3 style="margin:0">' + escapeHtml(g.SharedUsername) + status + '</h3>' +
|
||||
'<div class="fieldDescription">Members: ' + members + '</div>' +
|
||||
'<div class="fieldDescription">' +
|
||||
'Sync unwatched: ' + (g.SyncUnwatched ? 'yes' : 'no') +
|
||||
' · Sync play count: ' + (g.SyncPlayCount ? 'yes' : 'no') + '</div>' +
|
||||
capHtml +
|
||||
'<div class="fieldDescription" style="opacity:.8">' + describeRestrictions(g.Restrictions) + '</div>' +
|
||||
'<div style="margin-top:.4em">' +
|
||||
'<button is="emby-button" type="button" class="raised btnEditGroup" data-id="' + g.SharedUserId + '">' +
|
||||
'<span>Edit</span></button> ' +
|
||||
'<button is="emby-button" type="button" class="raised btnDeleteGroup" ' +
|
||||
'data-id="' + g.SharedUserId + '" data-name="' + g.SharedUsername + '">' +
|
||||
'data-id="' + g.SharedUserId + '" data-name="' + escapeHtml(g.SharedUsername) + '">' +
|
||||
'<span>Delete</span></button>' +
|
||||
'</div>' +
|
||||
'<form class="editGroupForm" data-id="' + g.SharedUserId + '" style="display:none;margin:.8em 0 .4em 1em">' +
|
||||
'<div class="checkboxContainer"><label class="emby-checkbox-label">' +
|
||||
'<input type="checkbox" is="emby-checkbox" name="SyncUnwatched"' + (g.SyncUnwatched ? ' checked' : '') + ' />' +
|
||||
'<span>Sync unwatched</span></label></div>' +
|
||||
'<div class="checkboxContainer"><label class="emby-checkbox-label">' +
|
||||
'<input type="checkbox" is="emby-checkbox" name="SyncPlayCount"' + (g.SyncPlayCount ? ' checked' : '') + ' />' +
|
||||
'<span>Sync play count</span></label></div>' +
|
||||
'<div class="checkboxContainer"><label class="emby-checkbox-label">' +
|
||||
'<input type="checkbox" is="emby-checkbox" name="IsDisabled"' + (g.IsDisabled ? ' checked' : '') + ' />' +
|
||||
'<span>Disabled</span></label></div>' +
|
||||
'<button is="emby-button" type="submit" class="raised button-submit emby-button"><span>Save</span></button> ' +
|
||||
'<button is="emby-button" type="button" class="raised btnCancelEdit emby-button"><span>Cancel</span></button>' +
|
||||
'</form>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
function groupById(id) {
|
||||
return groups.filter(function (g) { return g.SharedUserId === id; })[0];
|
||||
}
|
||||
|
||||
container.querySelectorAll('.ratingCapSlider').forEach(function (slider) {
|
||||
var group = groupById(slider.getAttribute('data-id'));
|
||||
var stops = capStops(group);
|
||||
var text = container.querySelector('.ratingCapText[data-id="' + group.SharedUserId + '"]');
|
||||
slider.getBubbleText = function (value) { return stops[Number(value)].label; };
|
||||
// Describe while dragging; only save on release.
|
||||
slider.addEventListener('input', function () {
|
||||
var i = Number(slider.value);
|
||||
text.innerHTML = describeCap(group, stops[i], i);
|
||||
});
|
||||
slider.addEventListener('change', function () {
|
||||
var i = Number(slider.value);
|
||||
updateGroup(group, {
|
||||
InheritParentalRating: i === 0,
|
||||
ParentalRatingCap: stops[i].value
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.btnEditGroup').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var form = container.querySelector('.editGroupForm[data-id="' + btn.getAttribute('data-id') + '"]');
|
||||
form.style.display = form.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.btnCancelEdit').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
btn.closest('.editGroupForm').style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.editGroupForm').forEach(function (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
updateGroup(groupById(form.getAttribute('data-id')), {
|
||||
SyncUnwatched: form.querySelector('[name=SyncUnwatched]').checked,
|
||||
SyncPlayCount: form.querySelector('[name=SyncPlayCount]').checked,
|
||||
IsDisabled: form.querySelector('[name=IsDisabled]').checked
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.btnDeleteGroup').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var id = btn.getAttribute('data-id');
|
||||
@@ -165,7 +391,7 @@
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
Promise.all([
|
||||
loadGroups(),
|
||||
loadRatingLevels().then(loadGroups),
|
||||
loadEligibleUsers(),
|
||||
ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) {
|
||||
page.querySelector('#NameSeparator').value = config.NameSeparator || '+';
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Net.Mime;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.WatchedTogether.Compat;
|
||||
using Jellyfin.Plugin.WatchedTogether.Configuration;
|
||||
using Jellyfin.Plugin.WatchedTogether.Models;
|
||||
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||
using MediaBrowser.Common.Api;
|
||||
@@ -25,6 +26,7 @@ namespace Jellyfin.Plugin.WatchedTogether.Controllers;
|
||||
public class WatchedTogetherController : ControllerBase
|
||||
{
|
||||
private readonly IProvisioningService _provisioningService;
|
||||
private readonly IRestrictionService _restrictionService;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILogger<WatchedTogetherController> _logger;
|
||||
|
||||
@@ -32,14 +34,17 @@ public class WatchedTogetherController : ControllerBase
|
||||
/// Initializes a new instance of the <see cref="WatchedTogetherController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="provisioningService">The provisioning service.</param>
|
||||
/// <param name="restrictionService">The content restriction service.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public WatchedTogetherController(
|
||||
IProvisioningService provisioningService,
|
||||
IRestrictionService restrictionService,
|
||||
IUserManager userManager,
|
||||
ILogger<WatchedTogetherController> logger)
|
||||
{
|
||||
_provisioningService = provisioningService;
|
||||
_restrictionService = restrictionService;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -58,21 +63,7 @@ public class WatchedTogetherController : ControllerBase
|
||||
return Ok(Array.Empty<GroupDto>());
|
||||
}
|
||||
|
||||
var groups = config.Groups.Select(g => new GroupDto
|
||||
{
|
||||
SharedUserId = g.SharedUserId,
|
||||
SharedUsername = _userManager.GetUserById(g.SharedUserId)?.Username ?? "(deleted)",
|
||||
SyncUnwatched = g.SyncUnwatched,
|
||||
SyncPlayCount = g.SyncPlayCount,
|
||||
IsDisabled = g.IsDisabled,
|
||||
Members = g.MemberUserIds.Select(id => new MemberDto
|
||||
{
|
||||
UserId = id,
|
||||
Username = _userManager.GetUserById(id)?.Username ?? "(deleted)"
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
return Ok(groups);
|
||||
return Ok(config.Groups.Select(ToDto).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -114,19 +105,7 @@ public class WatchedTogetherController : ControllerBase
|
||||
request.MemberUserIds,
|
||||
request.Name).ConfigureAwait(false);
|
||||
|
||||
return Ok(new GroupDto
|
||||
{
|
||||
SharedUserId = group.SharedUserId,
|
||||
SharedUsername = _userManager.GetUserById(group.SharedUserId)?.Username ?? string.Empty,
|
||||
SyncUnwatched = group.SyncUnwatched,
|
||||
SyncPlayCount = group.SyncPlayCount,
|
||||
IsDisabled = group.IsDisabled,
|
||||
Members = group.MemberUserIds.Select(id => new MemberDto
|
||||
{
|
||||
UserId = id,
|
||||
Username = _userManager.GetUserById(id)?.Username ?? "(deleted)"
|
||||
}).ToList()
|
||||
});
|
||||
return Ok(ToDto(group));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -157,7 +136,9 @@ public class WatchedTogetherController : ControllerBase
|
||||
request.MemberUserIds,
|
||||
request.SyncUnwatched,
|
||||
request.SyncPlayCount,
|
||||
request.IsDisabled).ConfigureAwait(false);
|
||||
request.IsDisabled,
|
||||
request.InheritParentalRating,
|
||||
request.ParentalRatingCap).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
@@ -192,4 +173,51 @@ public class WatchedTogetherController : ControllerBase
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a stored group against current user records, including the restrictions its
|
||||
/// shared account actually carries right now.
|
||||
/// </summary>
|
||||
/// <param name="group">The stored group.</param>
|
||||
/// <returns>The group as the dashboard shows it.</returns>
|
||||
private GroupDto ToDto(SharedGroup group)
|
||||
{
|
||||
var sharedUser = _userManager.GetUserById(group.SharedUserId);
|
||||
var range = _restrictionService.GetRatingRange(group.MemberUserIds);
|
||||
|
||||
return new GroupDto
|
||||
{
|
||||
SharedUserId = group.SharedUserId,
|
||||
SharedUsername = sharedUser?.Username ?? "(deleted)",
|
||||
SyncUnwatched = group.SyncUnwatched,
|
||||
SyncPlayCount = group.SyncPlayCount,
|
||||
IsDisabled = group.IsDisabled,
|
||||
InheritParentalRating = group.InheritParentalRating,
|
||||
ParentalRatingCap = group.ParentalRatingCap,
|
||||
StrictestMemberRating = range.Strictest,
|
||||
LoosestMemberRating = range.Loosest,
|
||||
Restrictions = sharedUser is null ? null : ToDto(ContentRestrictions.FromUser(sharedUser)),
|
||||
Members = group.MemberUserIds.Select(id =>
|
||||
{
|
||||
var member = _userManager.GetUserById(id);
|
||||
return new MemberDto
|
||||
{
|
||||
UserId = id,
|
||||
Username = member?.Username ?? "(deleted)",
|
||||
MaxParentalRating = member is null ? 0 : member.MaxParentalRatingScore
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static RestrictionsDto ToDto(ContentRestrictions restrictions)
|
||||
=> new()
|
||||
{
|
||||
MaxParentalRating = restrictions.MaxParentalRatingScore,
|
||||
MaxParentalSubRating = restrictions.MaxParentalRatingSubScore,
|
||||
BlockUnratedItems = restrictions.BlockUnratedItems.Select(u => u.ToString()).OrderBy(u => u, StringComparer.Ordinal).ToList(),
|
||||
BlockedTags = restrictions.BlockedTags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
AllowedTags = (restrictions.AllowedTags ?? Enumerable.Empty<string>()).OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
AllowsNothing = restrictions.HasAllowedTags && restrictions.AllowedTags!.Count == 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,4 +37,32 @@ public class GroupDto
|
||||
/// Gets or sets a value indicating whether the group is suspended.
|
||||
/// </summary>
|
||||
public bool IsDisabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the rating cap is the strictest member's.
|
||||
/// </summary>
|
||||
public bool InheritParentalRating { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the chosen rating cap when not inheriting, as Jellyfin's numeric score
|
||||
/// (<c>null</c> for none).
|
||||
/// </summary>
|
||||
public int? ParentalRatingCap { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the strictest member's rating cap, or <c>null</c> if no member has one - in
|
||||
/// which case there is nothing to choose.
|
||||
/// </summary>
|
||||
public int? StrictestMemberRating { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the loosest member's rating cap, or <c>null</c> if some member has none. The
|
||||
/// chosen cap can go no looser than this: past it nobody could unlock the account.
|
||||
/// </summary>
|
||||
public int? LoosestMemberRating { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the restrictions the shared account currently has.
|
||||
/// </summary>
|
||||
public RestrictionsDto? Restrictions { get; set; }
|
||||
}
|
||||
|
||||
@@ -16,4 +16,10 @@ public class MemberDto
|
||||
/// Gets or sets the member's username.
|
||||
/// </summary>
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the member's own parental rating cap, as Jellyfin's numeric score, or
|
||||
/// <c>null</c> for none. Shown so an admin can see which members a chosen cap shuts out.
|
||||
/// </summary>
|
||||
public int? MaxParentalRating { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The content restrictions currently in effect on a shared account, for display.
|
||||
/// </summary>
|
||||
public class RestrictionsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the parental rating cap as Jellyfin's numeric score, or <c>null</c> for none.
|
||||
/// </summary>
|
||||
public int? MaxParentalRating { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the sub-score cap that applies at the rating cap, or <c>null</c> for any.
|
||||
/// </summary>
|
||||
public int? MaxParentalSubRating { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the kinds of unrated item that are blocked.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> BlockUnratedItems { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tags that hide an item.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> BlockedTags { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tags an item must carry one of. Empty means no whitelist.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AllowedTags { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the whitelist is in force but lets nothing through,
|
||||
/// because the members' allowed-tag lists have nothing in common.
|
||||
/// </summary>
|
||||
public bool AllowsNothing { get; set; }
|
||||
}
|
||||
@@ -27,4 +27,15 @@ public class UpdateGroupRequest
|
||||
/// Gets or sets a value indicating whether the group is suspended.
|
||||
/// </summary>
|
||||
public bool IsDisabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the rating cap is the strictest member's.
|
||||
/// </summary>
|
||||
public bool InheritParentalRating { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the rating cap to use when not inheriting, as Jellyfin's numeric score
|
||||
/// (<c>null</c> for none). Kept within the members' range by the server.
|
||||
/// </summary>
|
||||
public int? ParentalRatingCap { get; set; }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
serviceCollection.AddSingleton<IGroupService, GroupService>();
|
||||
serviceCollection.AddSingleton<ILibraryAccessService, LibraryAccessService>();
|
||||
serviceCollection.AddSingleton<IRestrictionService, RestrictionService>();
|
||||
serviceCollection.AddSingleton<IProvisioningService, ProvisioningService>();
|
||||
serviceCollection.AddSingleton<IDynamicGroupService, DynamicGroupService>();
|
||||
|
||||
@@ -28,6 +29,8 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
||||
provider => new Lazy<IGroupService>(provider.GetRequiredService<IGroupService>));
|
||||
serviceCollection.AddSingleton(
|
||||
provider => new Lazy<IDynamicGroupService>(provider.GetRequiredService<IDynamicGroupService>));
|
||||
serviceCollection.AddSingleton(
|
||||
provider => new Lazy<IRestrictionService>(provider.GetRequiredService<IRestrictionService>));
|
||||
|
||||
// Discovered by Jellyfin and matched to shared accounts via User.AuthenticationProviderId.
|
||||
serviceCollection.AddSingleton<IAuthenticationProvider, SharedAccountAuthenticationProvider>();
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
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);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IProvisioningService _provisioningService;
|
||||
private readonly IRestrictionService _restrictionService;
|
||||
private readonly ICryptoProvider _cryptoProvider;
|
||||
private readonly ILogger<DynamicGroupService> _logger;
|
||||
|
||||
@@ -39,16 +40,19 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
/// </summary>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="provisioningService">The provisioning service.</param>
|
||||
/// <param name="restrictionService">The content restriction service.</param>
|
||||
/// <param name="cryptoProvider">The crypto provider.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DynamicGroupService(
|
||||
IUserManager userManager,
|
||||
IProvisioningService provisioningService,
|
||||
IRestrictionService restrictionService,
|
||||
ICryptoProvider cryptoProvider,
|
||||
ILogger<DynamicGroupService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_provisioningService = provisioningService;
|
||||
_restrictionService = restrictionService;
|
||||
_cryptoProvider = cryptoProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -155,6 +159,17 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
return null;
|
||||
}
|
||||
|
||||
// The same unlock rule the authentication provider applies to a resolved account: an
|
||||
// existing group may have had its rating cap raised in the dashboard since it was created.
|
||||
if (!_restrictionService.IsAtLeastAsStrict(existingUser, matched))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Rejected login for {Username} by member {MemberUsername}: the shared account is less restricted than the member",
|
||||
existingUser.Username,
|
||||
matched.Username);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Login as {Entered} resolved to the existing shared account {Username}",
|
||||
enteredUsername,
|
||||
@@ -165,8 +180,9 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
|
||||
// Passing no name lets provisioning generate the canonical alphabetically-sorted one, so
|
||||
// the account is named the same whichever order the members were typed in.
|
||||
// The account is limited to the libraries all named members share, so creating one at the
|
||||
// login screen cannot grant anybody access they did not already have.
|
||||
// The account is limited to the libraries all named members share and inherits their
|
||||
// strictest restrictions before this returns, so creating one at the login screen cannot
|
||||
// grant anybody access they did not already have - not even for the session it creates.
|
||||
var group = await _provisioningService.CreateGroupAsync(memberIds, null).ConfigureAwait(false);
|
||||
|
||||
var sharedUser = _userManager.GetUserById(group.SharedUserId);
|
||||
|
||||
@@ -30,13 +30,20 @@ public interface IProvisioningService
|
||||
/// <param name="syncUnwatched">Whether unwatched state propagates too.</param>
|
||||
/// <param name="syncPlayCount">Whether play counts are raised on watch.</param>
|
||||
/// <param name="isDisabled">Whether the group is suspended.</param>
|
||||
/// <returns>The updated group.</returns>
|
||||
/// <param name="inheritParentalRating">Whether the rating cap is the strictest member's.</param>
|
||||
/// <param name="parentalRatingCap">
|
||||
/// The rating cap to use instead, as Jellyfin's numeric score (<c>null</c> for none). Ignored
|
||||
/// when inheriting. Kept within the members' range: see <see cref="SharedGroup.ParentalRatingCap"/>.
|
||||
/// </param>
|
||||
/// <returns>The updated group, with the cap as actually stored.</returns>
|
||||
Task<SharedGroup> UpdateGroupAsync(
|
||||
Guid sharedUserId,
|
||||
IReadOnlyList<Guid> memberIds,
|
||||
bool syncUnwatched,
|
||||
bool syncPlayCount,
|
||||
bool isDisabled);
|
||||
bool isDisabled,
|
||||
bool inheritParentalRating,
|
||||
int? parentalRatingCap);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a group, optionally deleting its shared account.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Plugin.WatchedTogether.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Computes, applies and checks the content restrictions a shared account should have.
|
||||
/// </summary>
|
||||
public interface IRestrictionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the strictest combination of the given members' restrictions.
|
||||
/// </summary>
|
||||
/// <param name="memberIds">The members to combine.</param>
|
||||
/// <returns>Restrictions at least as strict as every member's.</returns>
|
||||
ContentRestrictions ComputeStrictest(IReadOnlyList<Guid> memberIds);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the range the members' parental rating caps span.
|
||||
/// </summary>
|
||||
/// <param name="memberIds">The members to inspect.</param>
|
||||
/// <returns>The strictest and loosest caps among them.</returns>
|
||||
RatingRange GetRatingRange(IReadOnlyList<Guid> memberIds);
|
||||
|
||||
/// <summary>
|
||||
/// Writes the restrictions a group's shared account should have: the strictest member's,
|
||||
/// except for the rating cap when the group has chosen its own.
|
||||
/// </summary>
|
||||
/// <param name="group">The group whose shared account to update.</param>
|
||||
/// <returns>
|
||||
/// The restrictions written, and whether the group's chosen cap had to be adjusted to stay
|
||||
/// within its members' range - in which case <paramref name="group"/> has been updated in
|
||||
/// place and the caller should persist it.
|
||||
/// </returns>
|
||||
Task<RestrictionApplyResult> ApplyAsync(SharedGroup group);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether <paramref name="candidate"/> hides at least everything
|
||||
/// <paramref name="member"/> cannot see, reading both users live.
|
||||
/// </summary>
|
||||
/// <param name="candidate">The shared account being unlocked.</param>
|
||||
/// <param name="member">The member whose password matched.</param>
|
||||
/// <returns><c>true</c> if the member may unlock the account.</returns>
|
||||
bool IsAtLeastAsStrict(User candidate, User member);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The range spanned by a set of members' parental rating caps.
|
||||
/// </summary>
|
||||
/// <param name="Strictest">The lowest cap, or <c>null</c> if no member has one.</param>
|
||||
/// <param name="Loosest">The highest cap, or <c>null</c> if any member has none.</param>
|
||||
public readonly record struct RatingRange(int? Strictest, int? Loosest)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether there is anything to choose: at least one member is capped.
|
||||
/// </summary>
|
||||
public bool HasChoice => Strictest is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of applying a group's restrictions to its shared account.
|
||||
/// </summary>
|
||||
/// <param name="Restrictions">What was written.</param>
|
||||
/// <param name="CapAdjusted">Whether the group's chosen cap was changed to fit its members' range.</param>
|
||||
public sealed record RestrictionApplyResult(ContentRestrictions Restrictions, bool CapAdjusted);
|
||||
@@ -33,6 +33,7 @@ public class ProvisioningService : IProvisioningService
|
||||
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryAccessService _libraryAccessService;
|
||||
private readonly IRestrictionService _restrictionService;
|
||||
private readonly ILogger<ProvisioningService> _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -40,14 +41,17 @@ public class ProvisioningService : IProvisioningService
|
||||
/// </summary>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="libraryAccessService">The library access service.</param>
|
||||
/// <param name="restrictionService">The content restriction service.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public ProvisioningService(
|
||||
IUserManager userManager,
|
||||
ILibraryAccessService libraryAccessService,
|
||||
IRestrictionService restrictionService,
|
||||
ILogger<ProvisioningService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryAccessService = libraryAccessService;
|
||||
_restrictionService = restrictionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -119,14 +123,17 @@ public class ProvisioningService : IProvisioningService
|
||||
sharedUser.AuthenticationProviderId = AuthProviderId;
|
||||
await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
|
||||
|
||||
await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false);
|
||||
|
||||
var group = new SharedGroup
|
||||
{
|
||||
SharedUserId = sharedUser.Id,
|
||||
MemberUserIds = distinctIds
|
||||
};
|
||||
|
||||
// Restrict before the group is recorded, so an account created from the login screen is
|
||||
// never usable, even once, with fewer restrictions than its members have. A new group
|
||||
// always inherits; a cap is chosen afterwards in the dashboard.
|
||||
await ApplyDerivedPolicyAsync(group).ConfigureAwait(false);
|
||||
|
||||
config.Groups.Add(group);
|
||||
plugin.UpdateConfiguration(config);
|
||||
|
||||
@@ -145,7 +152,9 @@ public class ProvisioningService : IProvisioningService
|
||||
IReadOnlyList<Guid> memberIds,
|
||||
bool syncUnwatched,
|
||||
bool syncPlayCount,
|
||||
bool isDisabled)
|
||||
bool isDisabled,
|
||||
bool inheritParentalRating,
|
||||
int? parentalRatingCap)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memberIds);
|
||||
|
||||
@@ -188,18 +197,25 @@ public class ProvisioningService : IProvisioningService
|
||||
group.SyncUnwatched = syncUnwatched;
|
||||
group.SyncPlayCount = syncPlayCount;
|
||||
group.IsDisabled = isDisabled;
|
||||
group.InheritParentalRating = inheritParentalRating;
|
||||
group.ParentalRatingCap = inheritParentalRating ? null : parentalRatingCap;
|
||||
|
||||
plugin.UpdateConfiguration(config);
|
||||
|
||||
// Membership drives library access, so recompute it: adding a member can only narrow the
|
||||
// intersection, and removing one may widen it.
|
||||
await ApplyLibraryAccessAsync(sharedUserId, distinctIds).ConfigureAwait(false);
|
||||
// Membership drives the derived policy, so recompute it: adding a member can only narrow
|
||||
// library access and tighten restrictions, and removing one may widen either. The chosen
|
||||
// cap may be pulled back into the new members' range, in which case it is saved again.
|
||||
if (await ApplyDerivedPolicyAsync(group).ConfigureAwait(false))
|
||||
{
|
||||
plugin.UpdateConfiguration(config);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated group {SharedUserId}: {MemberCount} members, disabled={IsDisabled}",
|
||||
"Updated group {SharedUserId}: {MemberCount} members, disabled={IsDisabled}, rating cap={RatingCap}",
|
||||
sharedUserId,
|
||||
distinctIds.Count,
|
||||
isDisabled);
|
||||
isDisabled,
|
||||
group.InheritParentalRating ? "inherited" : group.ParentalRatingCap?.ToString(CultureInfo.InvariantCulture) ?? "none");
|
||||
|
||||
return group;
|
||||
}
|
||||
@@ -266,24 +282,30 @@ public class ProvisioningService : IProvisioningService
|
||||
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||
|
||||
/// <summary>
|
||||
/// Sets library access on the shared account.
|
||||
/// Writes everything about the shared account's policy that follows from its membership:
|
||||
/// library access and content restrictions.
|
||||
/// </summary>
|
||||
/// <param name="sharedUserId">The shared account.</param>
|
||||
/// <param name="memberIds">The members whose access is intersected.</param>
|
||||
/// <returns>A task representing the update.</returns>
|
||||
private async Task ApplyLibraryAccessAsync(Guid sharedUserId, IReadOnlyList<Guid> memberIds)
|
||||
/// <param name="group">The group whose shared account to update.</param>
|
||||
/// <returns><c>true</c> if the group's chosen rating cap was adjusted and needs saving.</returns>
|
||||
private async Task<bool> ApplyDerivedPolicyAsync(SharedGroup group)
|
||||
{
|
||||
var user = _userManager.GetUserById(sharedUserId);
|
||||
var user = _userManager.GetUserById(group.SharedUserId);
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never "all folders": the shared account gets an explicit list of the libraries every
|
||||
// member can already reach, so joining a group can never grant access to anything.
|
||||
// member can already reach, so joining a group can never grant access to anything. And
|
||||
// never an administrator: the account is a union of other people's credentials and must
|
||||
// not carry a privilege none of them individually hold.
|
||||
user.SetPermission(PermissionKind.EnableAllFolders, false);
|
||||
user.SetPermission(PermissionKind.IsAdministrator, false);
|
||||
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||
|
||||
await _libraryAccessService.ApplyIntersectionAsync(sharedUserId, memberIds).ConfigureAwait(false);
|
||||
await _libraryAccessService.ApplyIntersectionAsync(group.SharedUserId, group.MemberUserIds).ConfigureAwait(false);
|
||||
|
||||
var applied = await _restrictionService.ApplyAsync(group).ConfigureAwait(false);
|
||||
return applied.CapAdjusted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public class RestrictionService : IRestrictionService
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILogger<RestrictionService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RestrictionService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public RestrictionService(IUserManager userManager, ILogger<RestrictionService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ContentRestrictions ComputeStrictest(IReadOnlyList<Guid> 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!;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public RatingRange GetRatingRange(IReadOnlyList<Guid> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RestrictionApplyResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a group's chosen cap within its members' range, turning a choice that has become
|
||||
/// meaningless back into inheritance.
|
||||
/// </summary>
|
||||
/// <param name="group">The group to adjust in place.</param>
|
||||
/// <returns><c>true</c> if anything changed.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAtLeastAsStrict(User candidate, User member)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(candidate);
|
||||
ArgumentNullException.ThrowIfNull(member);
|
||||
|
||||
return ContentRestrictions.FromUser(candidate)
|
||||
.IsAtLeastAsStrictAs(ContentRestrictions.FromUser(member));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public sealed class UserLifecycleService : IHostedService
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IGroupService _groupService;
|
||||
private readonly ILibraryAccessService _libraryAccessService;
|
||||
private readonly IRestrictionService _restrictionService;
|
||||
private readonly ILogger<UserLifecycleService> _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -31,16 +32,19 @@ public sealed class UserLifecycleService : IHostedService
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="groupService">The group service.</param>
|
||||
/// <param name="libraryAccessService">The library access service.</param>
|
||||
/// <param name="restrictionService">The content restriction service.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public UserLifecycleService(
|
||||
IUserManager userManager,
|
||||
IGroupService groupService,
|
||||
ILibraryAccessService libraryAccessService,
|
||||
IRestrictionService restrictionService,
|
||||
ILogger<UserLifecycleService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_groupService = groupService;
|
||||
_libraryAccessService = libraryAccessService;
|
||||
_restrictionService = restrictionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -50,7 +54,7 @@ public sealed class UserLifecycleService : IHostedService
|
||||
try
|
||||
{
|
||||
Reconcile();
|
||||
await ReapplyLibraryAccessAsync().ConfigureAwait(false);
|
||||
await ReapplyDerivedPolicyAsync().ConfigureAwait(false);
|
||||
}
|
||||
#pragma warning disable CA1031 // Reconciliation must never prevent the server from starting.
|
||||
catch (Exception ex)
|
||||
@@ -61,27 +65,40 @@ public sealed class UserLifecycleService : IHostedService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes every group's library access.
|
||||
/// Recomputes every group's library access and content restrictions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A member's own library access can be narrowed at any time through the normal user editor,
|
||||
/// which would leave a group's stored intersection too wide. Recomputing at startup brings
|
||||
/// shared accounts back in line without needing to hook every policy change.
|
||||
/// A member's own access can be narrowed at any time through the normal user editor, which
|
||||
/// would leave a group's stored policy too wide. Recomputing at startup brings shared accounts
|
||||
/// back in line without needing to hook every policy change. Between restarts the unlock rule
|
||||
/// in the authentication provider covers the security side of that drift.
|
||||
/// </remarks>
|
||||
/// <returns>A task representing the update.</returns>
|
||||
private async Task ReapplyLibraryAccessAsync()
|
||||
private async Task ReapplyDerivedPolicyAsync()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config is null)
|
||||
var plugin = Plugin.Instance;
|
||||
if (plugin is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var config = plugin.Configuration;
|
||||
var changed = false;
|
||||
|
||||
foreach (var group in config.Groups.ToList())
|
||||
{
|
||||
await _libraryAccessService
|
||||
.ApplyIntersectionAsync(group.SharedUserId, group.MemberUserIds)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// A member's cap may have moved since the group's own cap was chosen.
|
||||
var applied = await _restrictionService.ApplyAsync(group).ConfigureAwait(false);
|
||||
changed |= applied.CapAdjusted;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
plugin.UpdateConfiguration(config);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,13 @@ The sync is **one-way**: shared account → members. What Alice watches privatel
|
||||
never leaks into the shared account or onto Bob.
|
||||
|
||||
Library access is the **intersection** of the members', never the union: the group sees only what
|
||||
everyone in it could already see. Sharing an account is therefore never a way to reach a library you
|
||||
were not already allowed into.
|
||||
everyone in it could already see. Parental restrictions work the same way: the shared account
|
||||
inherits the *strictest* member's rating cap, unrated-item block and tag rules. Sharing an account
|
||||
is therefore never a way to reach something you were not already allowed to see.
|
||||
|
||||
A parent can deliberately **raise** a group's rating cap to watch something above a child's rating
|
||||
together. When they do, the child's own password stops unlocking the shared account, so raising
|
||||
the cap never becomes a way around it.
|
||||
|
||||
```
|
||||
login as "alice+bob+carol"
|
||||
@@ -139,6 +144,35 @@ Two consequences worth knowing:
|
||||
*different* member's password happened to be the one that matched, eventually locking out
|
||||
members who did nothing wrong.
|
||||
|
||||
A matching password is not the whole story. A member may only unlock a shared account that is **at
|
||||
least as restricted as they are**: after the password matches, both users' *live* policies are
|
||||
compared field by field (rating cap, unrated block, blocked tags, allowed tags) and the login is
|
||||
refused if the account is looser on any of them. With an inherited cap this always passes. With a
|
||||
chosen cap, the passwords of members stricter than it simply stop working on the group — logged at
|
||||
Information level, since a child trying the family account is expected, not an incident.
|
||||
|
||||
### Content restrictions
|
||||
|
||||
`RestrictionService` mirrors the library-access code for the parental fields on a user:
|
||||
|
||||
| Field | Combination |
|
||||
| --- | --- |
|
||||
| Parental rating cap (score, sub-score) | Lowest wins. Any cap beats none; at an equal score, any sub-cap beats none. |
|
||||
| Blocked unrated kinds | Union. |
|
||||
| Blocked tags | Union. |
|
||||
| Allowed tags (whitelist) | Only members *with* a whitelist constrain; their lists are intersected. An empty list in Jellyfin means "no whitelist", so when two whitelists have nothing in common the account is written a single tag no item carries (`watched-together:nothing`) — it must see nothing, not everything. |
|
||||
|
||||
A member that cannot be resolved contributes "fully restricted", on the same principle as
|
||||
library access. Access schedules and channel restrictions are **not** inherited yet.
|
||||
|
||||
The result is written to the shared account at creation, on every membership change, and at
|
||||
server startup — overwriting whatever was set on the account in the user editor. The one thing
|
||||
an admin can choose is the **rating cap**, anywhere between the strictest member's and the loosest
|
||||
member's; unrated and tag rules stay strictest-wins regardless. A chosen cap is kept inside that
|
||||
range whenever it is applied: at or below the strictest member it is simply inheritance, and past
|
||||
the loosest member nobody could unlock the account, so it is pulled back (and logged). The shared
|
||||
account is never an administrator.
|
||||
|
||||
### Watched-state sync
|
||||
|
||||
The plugin subscribes to `UserDataSaved` and decides per save reason what it means:
|
||||
@@ -214,6 +248,10 @@ Either way, a new user appears in your user list and can be renamed like any oth
|
||||
| Sync unwatched | on | Marking something *unwatched* on the shared account also marks it unwatched for every member. Turn this off to make sync additive: things only ever become watched. |
|
||||
| Sync play count | off | Raise a member's play count to at least 1 when an item becomes watched. Play counts are never decreased. |
|
||||
| Disabled | off | Suspends a group: it stops accepting logins and stops syncing, without deleting anything. |
|
||||
| Parental rating cap | strictest member | A slider from the strictest member's rating to the loosest member's (or "No cap"). At the left end the cap is inherited and every member can unlock the account. Move it right to let the group watch above a member's rating: members stricter than the chosen cap can no longer unlock the account with their password. The dashboard says who is in and who is out at each position. Not shown when no member has a cap. |
|
||||
|
||||
Unrated-item blocks and tag rules are always the strictest member's; the dashboard shows what is
|
||||
in effect under each group.
|
||||
|
||||
### Plugin settings
|
||||
|
||||
@@ -238,9 +276,19 @@ How this plugin bounds what a shared account can reach.
|
||||
Members with nothing in common produce an account that sees nothing.
|
||||
- **Blocked folders stay blocked.** An explicitly blocked library is subtracted even from a member
|
||||
who otherwise has "access to all libraries".
|
||||
- **Parental restrictions are inherited, strictest wins.** Rating cap, unrated-item block, blocked
|
||||
and allowed tags are combined so the shared account hides at least everything any member cannot
|
||||
see. Access schedules and channel restrictions are not inherited yet.
|
||||
- **Raising the cap cannot be used to get around it.** A member can only unlock a shared account
|
||||
that is at least as restricted as they are, checked against live policies at every login. A
|
||||
child's password stops opening a group the parent raised above the child's rating; the parent's
|
||||
still does. And the cap can never go past the loosest member's — there would be nobody left who
|
||||
could unlock it.
|
||||
- **A shared account is never an administrator.**
|
||||
- **The intersection is recomputed, not frozen.** It is recalculated whenever a group's membership
|
||||
changes, and re-applied to every group at server startup, so narrowing a member's own access
|
||||
narrows the groups they belong to.
|
||||
narrows the groups they belong to. Between restarts, the unlock rule covers the gap for
|
||||
restrictions: a member whose cap was lowered is refused until the group is recomputed.
|
||||
- **Disabled members are excluded.** A disabled Jellyfin user can no longer unlock the shared
|
||||
account, and no longer receives watched state.
|
||||
- **Shared accounts cannot be nested.** A shared account may not be a member of another group; this
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# Spec: parental restrictions on shared accounts
|
||||
|
||||
Status: phase 1 implemented (rating, unrated, tags, unlock rule, dashboard). Phase 2 (access
|
||||
schedules, channels) not started.
|
||||
|
||||
**Implemented design differs from the proposal below in one important way.** The `Inherit` /
|
||||
`Lifted` mode was replaced by a **chosen rating cap**: `SharedGroup.InheritParentalRating` (default
|
||||
true) and `SharedGroup.ParentalRatingCap`. The admin picks the cap on a slider between the
|
||||
strictest and the loosest member; unrated and tag rules stay strictest-wins. The unlock rule is
|
||||
unchanged and is what makes the slider safe - members stricter than the chosen cap can no longer
|
||||
unlock the account - and it also bounds the slider: past the loosest member nobody could unlock it,
|
||||
so the cap is clamped back into range whenever it is applied (create, update, startup). This
|
||||
removes the need to ever touch the user editor and cannot produce an account nobody can log into,
|
||||
which `Lifted` could. The rest of the proposal (combination rules, recompute points, dynamic
|
||||
groups always inheriting, never-administrator) stands.
|
||||
|
||||
Other implementation notes:
|
||||
|
||||
- **Allowed tags.** Jellyfin reads an *empty* allowed-tag list as "no whitelist" (unrestricted),
|
||||
not "allows nothing" — see `BaseItem.IsVisibleViaTags`. So members without a whitelist do not
|
||||
constrain, whitelists that exist are intersected, and an empty intersection is written as the
|
||||
single sentinel tag `watched-together:nothing`, which no item carries. `ContentRestrictions`
|
||||
reads that sentinel back as "whitelist in force, allows nothing".
|
||||
- `ApplyLibraryAccessAsync` became `ApplyDerivedPolicyAsync` on `ProvisioningService`, and also
|
||||
clears `IsAdministrator`.
|
||||
- The unlock rule is applied on the dynamic-login path too (`DynamicGroupService`, existing-group
|
||||
branch), since "kid+alice" typed against an "alice+kid" account with a raised cap reaches that code.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
A shared account currently inherits its members' *library* access (the intersection, see
|
||||
`LibraryAccessService`) but none of their *content* restrictions. A freshly provisioned shared
|
||||
account has Jellyfin's defaults for parental rating, unrated items, tags and access schedules,
|
||||
which means unrestricted.
|
||||
|
||||
Two consequences, given a parent `alice` and a child `kid` with a rating cap:
|
||||
|
||||
1. `alice+kid` can play anything in the libraries both can see, regardless of the child's cap.
|
||||
This is sometimes what the parent wants (watching something above the cap *together*).
|
||||
2. `kid` can log in as `alice+kid` **with their own password**, alone, and get around their own cap.
|
||||
This is never what anyone wants, and it contradicts the README's promise that "joining a group
|
||||
can never grant anyone access they did not already have".
|
||||
|
||||
The feature must allow (1) as an explicit opt-in while making (2) impossible in every mode.
|
||||
|
||||
## Design principle
|
||||
|
||||
> A member may only unlock a shared account that is **at least as restricted as they are**.
|
||||
|
||||
This is checked at authentication time against the members' *live* policies, so it holds even
|
||||
if the shared account's stored policy has drifted. With inheritance on it is true by
|
||||
construction. With inheritance lifted, a restricted member's password simply stops unlocking
|
||||
the group; an unrestricted member's still does. That is exactly the "parent decides" model: the
|
||||
parent can start a film above the child's rating on the shared account, the child cannot start
|
||||
it on their own.
|
||||
|
||||
## Per-group setting
|
||||
|
||||
Add to `SharedGroup`:
|
||||
|
||||
```csharp
|
||||
public RestrictionMode RestrictionMode { get; set; } = RestrictionMode.Inherit;
|
||||
```
|
||||
|
||||
| Mode | Shared account's restrictions | Who can unlock |
|
||||
| --- | --- | --- |
|
||||
| `Inherit` (default) | Recomputed from members, strictest wins. Overwrites whatever is set on the shared account in Jellyfin's user editor, same as library access does today. | Every eligible member (the unlock rule is always satisfied). |
|
||||
| `Lifted` | Left alone. Whatever the admin sets on the shared account in Jellyfin's user editor stands, default unrestricted. | Only members whose own restrictions are no stricter than the shared account's. |
|
||||
|
||||
Groups created at login (`DynamicGroupService`) are always `Inherit`. `Lifted` is a dashboard-only
|
||||
choice so nobody can widen access from the login screen.
|
||||
|
||||
Library access is unaffected by the mode: it stays an intersection in both.
|
||||
|
||||
## What "restrictions" covers, and how each is combined
|
||||
|
||||
All combinations are strictest-wins. A member that cannot be resolved contributes "fully
|
||||
restricted" (mirrors the library code's "treat as empty" rule).
|
||||
|
||||
| Field | Where it lives on `User` | Combination |
|
||||
| --- | --- | --- |
|
||||
| Parental rating | `MaxParentalRatingScore`, `MaxParentalRatingSubScore` (`int?`, null = unrestricted) | Minimum `(score, subScore)` tuple across members. Any non-null beats null. |
|
||||
| Block unrated | `PreferenceKind.BlockUnratedItems` (list of `UnratedItem`) | Union. |
|
||||
| Blocked tags | `PreferenceKind.BlockedTags` | Union. |
|
||||
| Allowed tags | `PreferenceKind.AllowedTags` (whitelist that overrides blocking) | Intersection. A member with an empty list allows nothing through this route, so the result is empty if any member's list is empty. |
|
||||
| Access schedules | `AccessSchedules` (per-day hour ranges; empty = always) | Phase 2, see below. |
|
||||
| Channels | `EnableAllChannels`, `EnabledChannels`, `BlockedChannels` | Same shape as libraries: intersection. Phase 2. |
|
||||
|
||||
Not in scope, but must hold as invariants regardless of mode: the shared account is never an
|
||||
administrator, never hidden-from-login-screen-exempt, and never gets remote access or content
|
||||
deletion that a member lacks. Add a test that asserts the first of these; the others can be
|
||||
follow-ups.
|
||||
|
||||
### Access schedules (phase 2)
|
||||
|
||||
Schedules are the awkward one because they are intervals, not sets. Correct combination is
|
||||
per-day interval intersection:
|
||||
|
||||
- A member with no schedules is unrestricted for that day.
|
||||
- For members with schedules, take the intersection of their windows on each day of the week.
|
||||
- If the intersection is empty on every day, the shared account can never log in; log a warning
|
||||
the same way the library code does for "no libraries in common".
|
||||
|
||||
Ship phase 1 without touching schedules, and document that. It is better to say "schedules are
|
||||
not inherited yet" than to get interval maths wrong on the first pass.
|
||||
|
||||
## The unlock rule
|
||||
|
||||
In `SharedAccountAuthenticationProvider`, after a submitted password matches a member `m`:
|
||||
|
||||
```
|
||||
if (!restrictions.IsAtLeastAsStrict(sharedUser, m)) reject with a log line
|
||||
```
|
||||
|
||||
`IsAtLeastAsStrict(a, b)` is true when every field of `a` is at least as restrictive as the
|
||||
same field of `b`, using the same comparisons as the table above. It reads both users live, no
|
||||
config involved.
|
||||
|
||||
This runs in both modes. In `Inherit` it is cheap insurance against drift between startup
|
||||
reapplies (a member's cap is lowered in the user editor; the shared account is not recomputed
|
||||
until restart; the unlock rule still refuses the member, correctly, until then). In `Lifted` it
|
||||
is the whole feature.
|
||||
|
||||
Log at Information, not Warning: a child trying their password on the family account after the
|
||||
parent lifted restrictions is expected, not an incident.
|
||||
|
||||
## When restrictions are recomputed
|
||||
|
||||
Same points as library access today, all in `Inherit` mode only:
|
||||
|
||||
- `ProvisioningService.CreateGroupAsync` and `UpdateGroupAsync`
|
||||
- `UserLifecycleService.StartAsync`
|
||||
- Switching a group from `Lifted` to `Inherit`
|
||||
|
||||
Switching to `Lifted` writes nothing. The shared account keeps whatever it had (which, if it was
|
||||
just in `Inherit`, is the strict computed policy). The admin then loosens it in Jellyfin's user
|
||||
editor if they want to. Consider a dashboard hint saying so, otherwise "I set Lifted and nothing
|
||||
changed" will be the first question.
|
||||
|
||||
## Implementation shape
|
||||
|
||||
Mirror `ILibraryAccessService`:
|
||||
|
||||
```csharp
|
||||
public interface IRestrictionService
|
||||
{
|
||||
ContentRestrictions ComputeStrictest(IReadOnlyList<Guid> memberIds);
|
||||
Task ApplyAsync(Guid sharedUserId, IReadOnlyList<Guid> memberIds);
|
||||
bool IsAtLeastAsStrict(User candidate, User member);
|
||||
}
|
||||
```
|
||||
|
||||
`ContentRestrictions` is a plain record of the phase 1 fields. Writes go through
|
||||
`IUserManager.UpdatePolicyAsync` with a `UserPolicy` built from the current one, exactly as
|
||||
`LibraryAccessService.ApplyIntersectionAsync` does, so the same 10.11 / 12 compat path is used.
|
||||
|
||||
Wire it into `ProvisioningService.ApplyLibraryAccessAsync` (rename to something like
|
||||
`ApplyDerivedPolicyAsync`) and `UserLifecycleService.ReapplyLibraryAccessAsync`.
|
||||
|
||||
## API and dashboard
|
||||
|
||||
- `GroupDto` / `UpdateGroupRequest` gain `RestrictionMode`.
|
||||
- `CreateGroupRequest` does not: new groups are always `Inherit`, change it afterwards.
|
||||
- Dashboard: the group list shows the mode. This is also the natural moment to add the per-group
|
||||
edit form that `SyncUnwatched` and `SyncPlayCount` currently lack (they are shown but not
|
||||
editable). One form, three controls.
|
||||
- Under the mode, in `Inherit`, show the effective result ("Rating: PG-13 (from kid), 2 blocked
|
||||
tags") so the admin can see what was computed. Cheap to add and it will answer most support
|
||||
questions before they are asked.
|
||||
|
||||
## Migration
|
||||
|
||||
Existing groups deserialise with `RestrictionMode = Inherit`, and the startup reapply will
|
||||
tighten their shared accounts on the first boot after upgrade. That is the right default (it is
|
||||
what the README already claims) but it is a behaviour change. Call it out in the release notes:
|
||||
"Shared accounts now inherit the strictest member's parental rating, unrated-item block and tags.
|
||||
If you relied on a shared account being unrestricted, set its group to Lifted in the dashboard."
|
||||
|
||||
## Interaction with watched-state sync
|
||||
|
||||
None required. In `Lifted` mode a parent watching an R-rated film on `alice+kid` marks it watched
|
||||
on `kid` too. `kid` cannot see the item anyway, so it is invisible; when they grow into the
|
||||
rating it shows as already watched, which is accurate. Not worth special-casing.
|
||||
|
||||
## Tests
|
||||
|
||||
`RestrictionTests.cs`, following `LibraryAccessTests.cs`:
|
||||
|
||||
- Rating: null + PG-13 = PG-13; PG + R = PG; sub-scores tie-break correctly.
|
||||
- Unrated / blocked tags: union. Allowed tags: intersection, empty if any member has none.
|
||||
- Unresolvable member yields fully restricted.
|
||||
- Unlock rule: restricted member rejected on a lifted group; unrestricted member accepted;
|
||||
every member accepted on an inherited group; a member whose cap was lowered *after* the last
|
||||
reapply is rejected on an inherited group (the drift case).
|
||||
- Dynamic creation always yields `Inherit`, and the shared account is restricted before the first
|
||||
login completes (the `kid` typing `alice+kid` with their own password on a fresh server must not
|
||||
get an unrestricted session, even once).
|
||||
- Provisioning: shared account is never an administrator.
|
||||
- End-to-end: parent + child, lifted, parent unlocks and plays above the cap, child is refused.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Should `Lifted` require the admin to confirm ("this lets the shared account exceed a member's
|
||||
rating")? A one-line description on the control is probably enough; a modal is overkill.
|
||||
2. Is there any value in a third mode where the admin sets restrictions on the shared account
|
||||
*and* they are enforced as a floor (never looser than members, may be stricter)? Probably not
|
||||
worth it until someone asks.
|
||||
3. Reapply on a timer rather than only at startup? Most Jellyfin servers run for weeks. The unlock
|
||||
rule covers the security side of drift; the only gap is a shared account staying too strict
|
||||
after a member's cap is *raised*, which fixes itself on restart and is harmless. Leave it.
|
||||
Reference in New Issue
Block a user