14 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 5120e7abba Set version to 0.1.0
🚀 Release Plugin / build-and-release (push) Successful in 1m43s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 12:30:50 +02:00
dtourolleandClaude Opus 5 27cd2a3d37 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>
2026-09-19 12:30:50 +02:00
dtourolleandClaude Opus 5 0c5fc9487c Set version to 0.0.6
🏗️ Build Plugin / build (push) Successful in 3m20s
🧪 Test Plugin / test (push) Successful in 1m23s
🚀 Release Plugin / build-and-release (push) Successful in 2m4s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 10:36:11 +02:00
dtourolleandClaude Opus 5 8ad727d870 Fix watched-state sync so members' Next Up and Continue Watching follow
Synced rows only ever had the Played flag set. Jellyfin computes Next Up
from LastPlayedDate on the member's own row and Continue Watching from
the resume position, so a member watching through the shared account got
the tick on each episode but their Next Up never advanced. Members are
now written the way BaseItem.MarkPlayed/MarkUnplayed write: date and
position included. Rows the old version ticked without a date are
repaired the next time the item syncs.

PlaybackFinished was also treated as an unwatched toggle. Jellyfin raises
it on every stop, not just completion (and on 10.11 PlaybackStart resets
Played to false first), so a stop halfway through on the shared account
cleared members' own watched state whenever Sync unwatched was on.
Playback-derived reasons (PlaybackFinished, PlaybackProgress,
UpdateUserData) now only ever mirror "watched"; TogglePlayed and Import
remain explicit and mirror either way. PlaybackProgress is acted on so
the tick lands as soon as the completion threshold is crossed, including
for clients that never report a stop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 10:36:11 +02:00
Gitea Actions a2ffbb3efe Update manifest.json for version 0.0.5 2026-09-11 17:32:26 +00:00
dtourolleandClaude Opus 5 5397017000 Set version to 0.0.5
🏗️ Build Plugin / build (push) Successful in 3m50s
🧪 Test Plugin / test (push) Successful in 1m7s
🚀 Release Plugin / build-and-release (push) Successful in 2m6s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 19:25:21 +02:00
dtourolleandClaude Opus 5 bd08629fff Support Jellyfin 12 alongside 10.11
Jellyfin 12 moved to .NET 10 and changed the IUserManager surface the
plugin relies on: Users/UsersIds became GetUsers()/GetUsersIds(),
ChangePassword takes a user id, HasPassword left the provider contract,
and the user cache is gone, so every lookup is a detached copy.

The plugin now multi-targets net9.0 (against 10.11.5) and net10.0
(against 12.0.0). The differences sit behind a JELLYFIN_12 constant in
Compat/UserManagerCompat.cs, whose ChangePasswordAsync also carries the
stored hash back onto the caller's instance: on 12 the UpdateUserAsync
that claims the account would otherwise write the stale null password
back over the one provisioning just set.

Each release ships one package per generation, with the fourth version
segment naming the target (x.y.z.11 and x.y.z.12) so a 12 server picks
the 12 package over the 10.11 one. scripts/package.sh wraps jprm for a
single generation and the workflows call it twice. The builder image
moves to the .NET 10 SDK, which builds both targets; the net9.0 test run
rolls forward onto the .NET 10 runtime.

CA1873 is a .NET 10 analyzer that flags the same log calls CA1848 does;
it is set to Info, as in the upstream Jellyfin 12 tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 19:25:16 +02:00
Gitea Actions 17bb9a1e8a Update manifest.json for version 0.0.4.0 2026-08-09 08:59:11 +00:00
dtourolleandClaude Opus 5 44ab98e082 Set version to 0.0.4
🏗️ Build Plugin / build (push) Successful in 36s
🧪 Test Plugin / test (push) Successful in 34s
🚀 Release Plugin / build-and-release (push) Successful in 47s
Dynamic group creation fix on top of 0.0.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:56:36 +02:00
dtourolleandClaude Opus 5 bb8814644c Set the shared account's password before claiming it
Provisioning assigned AuthenticationProviderId and only then called
IUserManager.ChangePassword. Jellyfin dispatches that call to the provider the
user is currently assigned to, so it reached this plugin's own ChangePassword,
which refuses by design. Creating a group by typing "alice+bob" at the login
screen therefore died with NotSupportedException.

Set the placeholder password first, while the freshly created account is still
on Jellyfin's default provider, then claim it.

The new end-to-end tests wire the real provisioning, group and authentication
services together rather than mocking IProvisioningService, and cover a group
created on demand being unlocked afterwards by either member's password. With
the old ordering restored, six of them fail with the original exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:56:36 +02:00
Gitea Actions 69f7a87cef Update manifest.json for version 0.0.3.0 2026-08-09 08:49:28 +00:00
dtourolleandClaude Opus 5 fd08d7ea1a Set version to 0.0.3
🏗️ Build Plugin / build (push) Successful in 1m43s
🧪 Test Plugin / test (push) Successful in 37s
🚀 Release Plugin / build-and-release (push) Successful in 45s
Startup-crash fix on top of 0.0.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:46:09 +02:00
dtourolleandClaude Opus 5 da779514fe Resolve the group services lazily in the authentication provider
Jellyfin's UserManager constructor-injects every IAuthenticationProvider, so
building IUserManager forced SharedAccountAuthenticationProvider to be built
first. That provider eagerly required IGroupService and IDynamicGroupService,
both of which need IUserManager, and the container refused to start the server
with "a circular dependency was detected".

Take the two group services as Lazy<T> and dereference them at authentication
time instead. Nobody can log in before the host is up, so the deferred lookup
is always safe. Microsoft's container has no built-in Lazy<T> support, hence
the explicit factory registrations.

The accompanying test builds the service graph through a stand-in that mimics
UserManager's constructor shape and validates it on build, so a reintroduced
cycle fails in CI rather than at server startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:46:09 +02:00
Gitea Actions dfab79e92a Update manifest.json for version 0.0.2 2026-07-31 07:45:09 +00:00
38 changed files with 3068 additions and 219 deletions
+17 -12
View File
@@ -64,23 +64,28 @@ jobs:
working-directory: build-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.WatchedTogether.sln --no-build --configuration Release --verbosity normal
- name: Build Jellyfin Plugin
id: jprm
- name: Package for Jellyfin 10.11 and 12
working-directory: build-${{ github.run_id }}
run: |
mkdir -p artifacts
jprm --verbosity=debug plugin build .
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
LATEST="artifacts/watchedtogether_latest.zip"
cp "${ARTIFACT}" "${LATEST}"
echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
# One package per Jellyfin generation. Both carry the same date-based version, so they
# go to separate directories; the meta.json targetAbi inside each tells them apart.
scripts/package.sh 10.11 "${{ steps.version.outputs.version }}" artifacts/jellyfin-10.11
scripts/package.sh 12 "${{ steps.version.outputs.version }}" artifacts/jellyfin-12
find artifacts -name "*.zip" -type f
- name: Upload build artifact
- name: Upload Jellyfin 10.11 package
uses: actions/upload-artifact@v3
with:
name: watchedtogether-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
name: watchedtogether-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}-jellyfin-10.11
path: build-${{ github.run_id }}/artifacts/jellyfin-10.11/*.zip
retention-days: 30
if-no-files-found: error
- name: Upload Jellyfin 12 package
uses: actions/upload-artifact@v3
with:
name: watchedtogether-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}-jellyfin-12
path: build-${{ github.run_id }}/artifacts/jellyfin-12/*.zip
retention-days: 30
if-no-files-found: error
+49 -55
View File
@@ -31,16 +31,15 @@ jobs:
else
VERSION="${GITHUB_REF#refs/tags/}"
fi
# Tags are vX.Y.Z. The fourth version segment is reserved for the Jellyfin generation a
# package targets: X.Y.Z.11 for 10.11 and X.Y.Z.12 for 12. Jellyfin installs the highest
# version whose targetAbi it satisfies, so the 12 package must sort above the 10.11 one.
BASE=$(echo "${VERSION#v}" | cut -d. -f1-3)
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: ${VERSION}"
- name: Update build.yaml with version
working-directory: release-${{ github.run_id }}
run: |
VERSION="${{ steps.get_version.outputs.version_number }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
echo "version_number=${BASE}" >> $GITHUB_OUTPUT
echo "version_1011=${BASE}.11" >> $GITHUB_OUTPUT
echo "version_12=${BASE}.12" >> $GITHUB_OUTPUT
echo "Building version: ${VERSION} (${BASE}.11 for Jellyfin 10.11, ${BASE}.12 for Jellyfin 12)"
- name: Cache NuGet packages
uses: actions/cache@v3
@@ -61,25 +60,24 @@ jobs:
working-directory: release-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.WatchedTogether.sln --no-build --configuration Release --verbosity normal
- name: Build Jellyfin Plugin
- name: Package for Jellyfin 10.11 and 12
id: jprm
working-directory: release-${{ github.run_id }}
run: |
mkdir -p artifacts
jprm --verbosity=debug plugin build ./
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
scripts/package.sh 10.11 "${{ steps.get_version.outputs.version_1011 }}" artifacts
scripts/package.sh 12 "${{ steps.get_version.outputs.version_12 }}" artifacts
- name: Calculate checksum
id: checksum
working-directory: release-${{ github.run_id }}
run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}"
ARTIFACT_1011=$(find artifacts -name "*_${{ steps.get_version.outputs.version_1011 }}.zip" -type f -print -quit)
ARTIFACT_12=$(find artifacts -name "*_${{ steps.get_version.outputs.version_12 }}.zip" -type f -print -quit)
test -n "${ARTIFACT_1011}" && test -n "${ARTIFACT_12}"
echo "artifact_1011=${ARTIFACT_1011}" >> $GITHUB_OUTPUT
echo "artifact_name_1011=$(basename "${ARTIFACT_1011}")" >> $GITHUB_OUTPUT
echo "checksum_1011=$(md5sum "${ARTIFACT_1011}" | awk '{print $1}')" >> $GITHUB_OUTPUT
echo "artifact_12=${ARTIFACT_12}" >> $GITHUB_OUTPUT
echo "artifact_name_12=$(basename "${ARTIFACT_12}")" >> $GITHUB_OUTPUT
echo "checksum_12=$(md5sum "${ARTIFACT_12}" | awk '{print $1}')" >> $GITHUB_OUTPUT
echo "Packages: ${ARTIFACT_1011} ${ARTIFACT_12}"
- name: Create Release
working-directory: release-${{ github.run_id }}
@@ -95,7 +93,7 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-d "$(jq -n --arg tag "$VERSION" --arg name "Release $VERSION" --arg body "Watched Together Jellyfin plugin. See attached files for installation." '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')")
-d "$(jq -n --arg tag "$VERSION" --arg name "Release $VERSION" --arg body "Watched Together Jellyfin plugin. Two packages are attached: ${{ steps.jprm.outputs.artifact_name_1011 }} for Jellyfin 10.11 and ${{ steps.jprm.outputs.artifact_name_12 }} for Jellyfin 12. The plugin repository picks the right one automatically." '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
@@ -109,19 +107,14 @@ jobs:
exit 1
fi
echo "Uploading plugin artifact..."
curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
echo "Uploading build.yaml..."
curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/x-yaml" \
--data-binary "@build.yaml" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
for ARTIFACT in "${{ steps.jprm.outputs.artifact_1011 }}" "${{ steps.jprm.outputs.artifact_12 }}"; do
echo "Uploading ${ARTIFACT}..."
curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary "@${ARTIFACT}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ARTIFACT}")"
done
echo "✅ Release created successfully!"
@@ -133,32 +126,33 @@ jobs:
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
VERSION="${{ steps.get_version.outputs.version_number }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}/${ARTIFACT_NAME}"
DOWNLOAD_BASE="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}"
git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris"
git fetch origin master
git checkout master
NEW_VERSION=$(cat <<EOF
{
"version": "${VERSION}",
"changelog": "Release ${VERSION}",
"targetAbi": "10.11.0.0",
"sourceUrl": "${DOWNLOAD_URL}",
"checksum": "${CHECKSUM}",
"timestamp": "${TIMESTAMP}"
}
EOF
)
# One manifest entry per Jellyfin generation. A server only considers entries whose
# targetAbi it meets and installs the highest of those, so 10.11 sees only the .11 entry
# while 12 sees both and takes the .12 one.
NEW_VERSIONS=$(jq -n \
--arg ts "${TIMESTAMP}" \
--arg v12 "${{ steps.get_version.outputs.version_12 }}" \
--arg url12 "${DOWNLOAD_BASE}/${{ steps.jprm.outputs.artifact_name_12 }}" \
--arg sum12 "${{ steps.jprm.outputs.checksum_12 }}" \
--arg v1011 "${{ steps.get_version.outputs.version_1011 }}" \
--arg url1011 "${DOWNLOAD_BASE}/${{ steps.jprm.outputs.artifact_name_1011 }}" \
--arg sum1011 "${{ steps.jprm.outputs.checksum_1011 }}" \
'[
{version: $v12, changelog: ("Release " + $v12 + " (Jellyfin 12)"), targetAbi: "12.0.0.0", sourceUrl: $url12, checksum: $sum12, timestamp: $ts},
{version: $v1011, changelog: ("Release " + $v1011 + " (Jellyfin 10.11)"), targetAbi: "10.11.0.0", sourceUrl: $url1011, checksum: $sum1011, timestamp: $ts}
]')
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
jq --argjson newvers "${NEW_VERSIONS}" '.[0].versions = $newvers + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
git add manifest.json
git commit -m "Update manifest.json for version ${VERSION}"
git commit -m "Update manifest.json for version ${{ steps.get_version.outputs.version_number }}"
git push origin master
- name: Cleanup
+3 -3
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<Version>0.0.2.0</Version>
<AssemblyVersion>0.0.2.0</AssemblyVersion>
<FileVersion>0.0.2.0</FileVersion>
<Version>0.1.0.0</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
<FileVersion>0.1.0.0</FileVersion>
</PropertyGroup>
</Project>
+4 -2
View File
@@ -1,9 +1,11 @@
# Watched Together builder image
# Pre-built image with the .NET 9 SDK and JPRM for building and testing the plugin.
# Pre-built image with the .NET 10 SDK and JPRM for building and testing the plugin. The SDK builds
# both the net9.0 (Jellyfin 10.11) and net10.0 (Jellyfin 12) targets; the net9.0 test run rolls
# forward onto the .NET 10 runtime.
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/watchedtogether-builder:latest .
# Push: docker push gitea.tourolle.paris/dtourolle/watchedtogether-builder:latest
FROM mcr.microsoft.com/dotnet/sdk:9.0
FROM mcr.microsoft.com/dotnet/sdk:10.0
# nodejs is required by the runner itself, not by the build: actions/checkout and
# actions/cache are JavaScript actions, and the runner execs `node` inside this
@@ -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,
groups.Object,
dynamic.Object,
new Lazy<IGroupService>(() => groups.Object),
new Lazy<IDynamicGroupService>(() => dynamic.Object),
new Lazy<IRestrictionService>(() => restrictions.Object),
NullLogger<SharedAccountAuthenticationProvider>.Instance);
}
@@ -198,13 +203,16 @@ public class AuthenticationTests
Assert.Equal("alice+bob", result.Username);
}
#if !JELLYFIN_12
[Fact]
public void HasPassword_IsAlwaysTrue()
{
// Returning false would let a client offer a passwordless login for the shared account.
// Jellyfin 12 dropped this hook from the provider contract.
var provider = MakeProvider(null, []);
Assert.True(provider.HasPassword(MakeUser("alice+bob", null)));
}
#endif
[Fact]
public async Task ChangePassword_IsNotSupported()
@@ -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);
@@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<!-- Mirrors the plugin: each framework is tested against the Jellyfin generation it ships for. -->
<TargetFrameworks>net9.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- Test code is exempt from the strict analyzer profile the plugin itself uses. -->
@@ -10,13 +11,17 @@
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CA1707;SA0001;CS1591</NoWarn>
<!--
The plugin targets net9.0 to match Jellyfin 10.11's ABI, but a machine may only have a newer
runtime installed. Rolling the test host forward to the latest major lets the suite run
without pinning developers to a .NET 9 runtime.
The net9.0 build matches Jellyfin 10.11's ABI, but a machine may only have a newer runtime
installed. Rolling the test host forward to the latest major lets the suite run without
pinning developers to a .NET 9 runtime.
-->
<RollForward>LatestMajor</RollForward>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0'">
<DefineConstants>$(DefineConstants);JELLYFIN_12</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
@@ -28,14 +33,19 @@
<ProjectReference Include="../Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj" />
</ItemGroup>
<ItemGroup>
<!--
The plugin excludes the runtime assets of these packages because the Jellyfin server supplies
them at load time. Tests run without a server, so they need the real assemblies copied to the
output directory.
-->
<!--
The plugin excludes the runtime assets of these packages because the Jellyfin server supplies
them at load time. Tests run without a server, so they need the real assemblies copied to the
output directory.
-->
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
<PackageReference Include="Jellyfin.Model" Version="10.11.5" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
<PackageReference Include="Jellyfin.Model" Version="12.0.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,226 @@
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;
using Moq;
using Xunit;
namespace Jellyfin.Plugin.WatchedTogether.Tests;
/// <summary>
/// Covers shared-account provisioning against a user manager that dispatches password changes the
/// way Jellyfin's real one does.
/// </summary>
[Collection(nameof(PluginTestContext))]
public class ProvisioningTests
{
private static readonly string AuthProviderId =
typeof(Auth.SharedAccountAuthenticationProvider).FullName!;
private static User MakeUser(string name) => new(name, "Prov", "ResetProv");
/// <summary>
/// Builds a user manager that mimics the one behaviour that matters here: ChangePassword is
/// routed to the provider named by the user's AuthenticationProviderId, so an account already
/// claimed by us lands in our provider and is refused.
/// </summary>
private static Mock<IUserManager> MakeUserManager(List<User> users, List<string> callLog)
{
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.CreateUserAsync(It.IsAny<string>()))
.ReturnsAsync((string name) =>
{
var created = MakeUser(name);
users.Add(created);
callLog.Add("CreateUser");
return created;
});
userManager.SetupChangePassword(users, (user, password) =>
{
callLog.Add($"ChangePassword(provider={user.AuthenticationProviderId})");
// This is the dispatch that made provisioning fail in 0.0.3: once the account is
// claimed, the call reaches our provider, which refuses it by design.
if (string.Equals(user.AuthenticationProviderId, AuthProviderId, StringComparison.Ordinal))
{
return new Auth.SharedAccountAuthenticationProvider(
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);
}
user.Password = password;
return Task.CompletedTask;
});
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>()))
.Returns((User user) =>
{
callLog.Add($"UpdateUser(provider={user.AuthenticationProviderId})");
return Task.CompletedTask;
});
return userManager;
}
private static ProvisioningService MakeService(Mock<IUserManager> userManager)
{
// 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()
{
using var context = PluginTestContext.Create();
var alice = MakeUser("alice");
var bob = MakeUser("bob");
var users = new List<User> { alice, bob };
var callLog = new List<string>();
var service = MakeService(MakeUserManager(users, callLog));
// Before the fix this threw NotSupportedException from our own ChangePassword.
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
Assert.NotEqual(Guid.Empty, group.SharedUserId);
// The password must be set while the account is still on Jellyfin's default provider.
var changeIndex = callLog.FindIndex(c => c.StartsWith("ChangePassword", StringComparison.Ordinal));
var claimIndex = callLog.FindIndex(c => c.Contains(AuthProviderId, StringComparison.Ordinal));
Assert.True(changeIndex >= 0, "provisioning should set a password on the shared account");
Assert.True(claimIndex >= 0, "provisioning should claim the account for our provider");
Assert.True(
changeIndex < claimIndex,
$"password must be set before the account is claimed, but call order was: {string.Join(" -> ", callLog)}");
}
[Fact]
public async Task CreateGroupAsync_LeavesTheAccountClaimedByOurProvider()
{
using var context = PluginTestContext.Create();
var alice = MakeUser("alice");
var bob = MakeUser("bob");
var users = new List<User> { alice, bob };
var callLog = new List<string>();
var service = MakeService(MakeUserManager(users, callLog));
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
// Claiming the account is what routes its logins to us; provisioning is useless without it.
var sharedUser = users.Find(u => u.Id == group.SharedUserId);
Assert.NotNull(sharedUser);
Assert.Equal(AuthProviderId, sharedUser!.AuthenticationProviderId);
}
[Fact]
public async Task CreateGroupAsync_GivesTheSharedAccountANonEmptyPassword()
{
using var context = PluginTestContext.Create();
var alice = MakeUser("alice");
var bob = MakeUser("bob");
var users = new List<User> { alice, bob };
var callLog = new List<string>();
var service = MakeService(MakeUserManager(users, callLog));
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
// A passwordless shared account would be directly loginable if the provider were ever
// unassigned, which is the reason provisioning sets one at all.
var sharedUser = users.Find(u => u.Id == group.SharedUserId);
Assert.NotNull(sharedUser);
Assert.False(string.IsNullOrEmpty(sharedUser!.Password));
}
[Fact]
public async Task CreateGroupAsync_StoredPasswordSurvivesClaimingTheAccount()
{
using var context = PluginTestContext.Create();
// Models Jellyfin 12, where the user manager keeps no cache: every lookup returns a
// detached copy of the stored row, and UpdateUserAsync writes back every column of the
// instance it is handed. The random password provisioning sets must survive the provider
// assignment that is saved afterwards through a different instance.
var stored = new List<User> { MakeUser("alice"), MakeUser("bob") };
var userManager = new Mock<IUserManager>();
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>()))
.Returns((Guid id) => Copy(stored.Find(u => u.Id == id)));
userManager.Setup(m => m.CreateUserAsync(It.IsAny<string>()))
.ReturnsAsync((string name) =>
{
var row = MakeUser(name);
stored.Add(row);
return Copy(row)!;
});
// On 12 the helper resolves the stored row by id, so this writes the hash there and only
// there; on 10.11 it writes to the caller's instance, as the real server does.
userManager.SetupChangePassword(stored, (user, password) =>
{
user.Password = password;
return Task.CompletedTask;
});
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>()))
.Returns((User user) =>
{
var row = stored.Find(u => u.Id == user.Id)!;
row.Password = user.Password;
row.AuthenticationProviderId = user.AuthenticationProviderId;
return Task.CompletedTask;
});
var service = MakeService(userManager);
var group = await service.CreateGroupAsync([stored[0].Id, stored[1].Id], null);
var row = stored.Find(u => u.Id == group.SharedUserId);
Assert.NotNull(row);
Assert.Equal(AuthProviderId, row!.AuthenticationProviderId);
Assert.False(
string.IsNullOrEmpty(row.Password),
"claiming the account must not overwrite the password provisioning stored");
}
/// <summary>
/// Copies the columns provisioning touches into a fresh instance with the same id, the way a
/// cache-less user manager hands out rows.
/// </summary>
private static User? Copy(User? row)
=> row is null
? null
: new User(row.Username, row.AuthenticationProviderId, row.PasswordResetProviderId)
{
Id = row.Id,
Password = row.Password,
};
}
@@ -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));
}
}
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.WatchedTogether;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Cryptography;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Plugin.WatchedTogether.Tests;
/// <summary>
/// Guards the plugin's service graph against container-level cycles.
/// </summary>
/// <remarks>
/// Jellyfin's real <c>UserManager</c> constructor-injects <c>IEnumerable&lt;IAuthenticationProvider&gt;</c>.
/// That means any plugin service reachable eagerly from our authentication provider must not itself
/// require <see cref="IUserManager"/>, or the host dies at startup with "a circular dependency was
/// detected". A cycle like that is invisible to unit tests that construct services by hand, so these
/// tests build the graph the way the host does.
/// </remarks>
public class ServiceRegistrationTests
{
/// <summary>
/// Stands in for Jellyfin's UserManager, whose constructor takes every registered authentication
/// provider. Only the constructor shape matters here - it is what closes the cycle.
/// </summary>
private sealed class UserManagerWithAuthProviders
{
public UserManagerWithAuthProviders(IEnumerable<IAuthenticationProvider> authenticationProviders)
{
AuthenticationProviders = authenticationProviders;
}
public IEnumerable<IAuthenticationProvider> AuthenticationProviders { get; }
}
private static ServiceProvider BuildHostLikeProvider()
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
// Host services the plugin consumes, other than IUserManager.
services.AddSingleton(Mock.Of<ILibraryManager>());
services.AddSingleton(Mock.Of<IUserDataManager>());
services.AddSingleton(Mock.Of<ICryptoProvider>());
// IUserManager resolves through the fake UserManager so that building it forces every
// IAuthenticationProvider to be built first, exactly as the real host does.
services.AddSingleton<UserManagerWithAuthProviders>();
services.AddSingleton(provider =>
{
provider.GetRequiredService<UserManagerWithAuthProviders>();
return Mock.Of<IUserManager>();
});
new ServiceRegistrator().RegisterServices(services, Mock.Of<MediaBrowser.Controller.IServerApplicationHost>());
return services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateOnBuild = true,
ValidateScopes = true
});
}
[Fact]
public void PluginServices_ResolveWithoutCircularDependency()
{
using var provider = BuildHostLikeProvider();
// Resolving IUserManager is what the host does during startup, and is the exact path that
// previously threw InvalidOperationException for a circular dependency.
var userManager = provider.GetRequiredService<IUserManager>();
Assert.NotNull(userManager);
}
[Fact]
public void AuthenticationProvider_IsConstructedWithoutResolvingUserManager()
{
using var provider = BuildHostLikeProvider();
var authProviders = provider.GetRequiredService<IEnumerable<IAuthenticationProvider>>();
Assert.Contains(authProviders, p => p is Auth.SharedAccountAuthenticationProvider);
}
[Fact]
public void GroupServices_AreStillResolvableOnceTheHostIsUp()
{
using var provider = BuildHostLikeProvider();
// The Lazy<T> indirection must not change what the services resolve to at authentication
// time, and must hand back the same singletons the rest of the plugin uses.
var lazyGroupService = provider.GetRequiredService<Lazy<Services.IGroupService>>();
var lazyDynamicGroupService = provider.GetRequiredService<Lazy<Services.IDynamicGroupService>>();
Assert.Same(provider.GetRequiredService<Services.IGroupService>(), lazyGroupService.Value);
Assert.Same(provider.GetRequiredService<Services.IDynamicGroupService>(), lazyDynamicGroupService.Value);
}
}
@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Plugin.WatchedTogether.Auth;
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>
/// Exercises the whole path a real login takes: a group created on demand by typing "alice+bob",
/// then logged into again afterwards by each member in turn.
/// </summary>
/// <remarks>
/// The other suites mock <see cref="IProvisioningService"/>, which is why an ordering bug inside the
/// real provisioning code reached a release. These tests wire the real services together and only
/// stub the host's user manager and crypto.
/// </remarks>
[Collection(nameof(PluginTestContext))]
public class SharedAccountEndToEndTests
{
private const string AliceHash = "$PBKDF2-SHA512$iterations=210000$A1A1A1A1$AAAAAAAABBBBBBBB";
private const string BobHash = "$PBKDF2-SHA512$iterations=210000$B2B2B2B2$CCCCCCCCDDDDDDDD";
private static readonly string AuthProviderId =
typeof(SharedAccountAuthenticationProvider).FullName!;
private sealed record Harness(
SharedAccountAuthenticationProvider Provider,
List<User> Users);
private static User MakeUser(string name, string? password = null)
=> new(name, "Prov", "ResetProv") { Password = password! };
/// <summary>
/// Wires the real provisioning, group, dynamic-group and authentication services over a user
/// manager that behaves like Jellyfin's: password changes dispatch to the user's assigned
/// provider, and users resolve by both name and id.
/// </summary>
private static Harness MakeHarness(List<User> users, params (string Hash, string Password)[] validPairs)
{
var crypto = new StubCryptoProvider(validPairs);
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.SetupPolicyRoundTrip(users);
userManager.SetupChangePassword(users, (user, password) =>
{
// Jellyfin routes this to the user's assigned provider; ours refuses by design.
if (string.Equals(user.AuthenticationProviderId, AuthProviderId, StringComparison.Ordinal))
{
throw new NotSupportedException(
"A Watched Together shared account has no password of its own.");
}
user.Password = password;
return Task.CompletedTask;
});
var groupService = new GroupService(
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);
var provider = new SharedAccountAuthenticationProvider(
crypto,
new Lazy<IGroupService>(() => groupService),
new Lazy<IDynamicGroupService>(() => dynamicGroups),
new Lazy<IRestrictionService>(() => restrictions),
NullLogger<SharedAccountAuthenticationProvider>.Instance);
return new Harness(provider, users);
}
[Theory]
[InlineData("alice-pw")]
[InlineData("bob-pw")]
public async Task GroupCreatedOnDemand_ThenUnlockedByEitherMemberPassword(string creatingPassword)
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var bob = MakeUser("bob", BobHash);
var users = new List<User> { alice, bob };
var h = MakeHarness(users, (AliceHash, "alice-pw"), (BobHash, "bob-pw"));
// Creating the group by typing both names. Whichever member types their own password, the
// resulting account must behave identically.
var created = await h.Provider.Authenticate("alice+bob", creatingPassword, null);
Assert.Equal("alice+bob", created.Username);
var shared = h.Users.Find(u => u.Username == "alice+bob");
Assert.NotNull(shared);
// The account exists now, so Jellyfin resolves it and hands it to us as resolvedUser. Both
// members must be able to unlock it, regardless of who created it.
var asAlice = await h.Provider.Authenticate("alice+bob", "alice-pw", shared);
Assert.Equal("alice+bob", asAlice.Username);
var asBob = await h.Provider.Authenticate("alice+bob", "bob-pw", shared);
Assert.Equal("alice+bob", asBob.Username);
// And an outsider's password still must not.
await Assert.ThrowsAsync<AuthenticationException>(
() => h.Provider.Authenticate("alice+bob", "not-a-member-pw", shared!));
}
[Fact]
public async Task GroupCreatedOnDemand_ClaimsTheAccountAndKeepsAPassword()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var bob = MakeUser("bob", BobHash);
var users = new List<User> { alice, bob };
var h = MakeHarness(users, (AliceHash, "alice-pw"), (BobHash, "bob-pw"));
await h.Provider.Authenticate("alice+bob", "alice-pw", null);
var shared = h.Users.Find(u => u.Username == "alice+bob");
Assert.NotNull(shared);
// Claimed by us, so future logins route here, and holding a password of its own so it is
// not directly loginable if the provider is ever unassigned.
Assert.Equal(AuthProviderId, shared!.AuthenticationProviderId);
Assert.False(string.IsNullOrEmpty(shared.Password));
}
}
@@ -0,0 +1,119 @@
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;
/// <summary>
/// Sets up the <see cref="IUserManager"/> members whose signatures differ between Jellyfin 10.11
/// and 12, so the suites read the same whichever generation they are compiled against.
/// </summary>
internal static class UserManagerMockExtensions
{
/// <summary>
/// Routes <c>ChangePassword</c> to <paramref name="onChange"/> with the user the real server
/// would act on: the instance handed in on 10.11, the stored row looked up by id on 12.
/// </summary>
/// <param name="mock">The user manager mock.</param>
/// <param name="users">The users the mock knows about, used to resolve ids on 12.</param>
/// <param name="onChange">The behaviour to run for the change.</param>
public static void SetupChangePassword(
this Mock<IUserManager> mock,
List<User> users,
Func<User, string, Task> onChange)
{
#if JELLYFIN_12
mock.Setup(m => m.ChangePassword(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns((Guid id, string password) =>
{
var user = users.Find(u => u.Id == id)
?? throw new KeyNotFoundException($"No user with id {id}");
return onChange(user, password);
});
#else
mock.Setup(m => m.ChangePassword(It.IsAny<User>(), It.IsAny<string>()))
.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;
}
}
@@ -26,7 +26,7 @@ public class WatchedStateSyncTests
public Mock<IGroupService> Groups { get; } = new();
public List<(User Member, bool Played, int PlayCount)> Saves { get; } = new();
public List<Save> Saves { get; } = new();
public WatchedStateSyncService Service { get; private set; } = null!;
@@ -34,7 +34,9 @@ public class WatchedStateSyncTests
SharedGroup? group,
IReadOnlyList<User> members,
bool memberAlreadyPlayed = false,
int memberPlayCount = 0)
int memberPlayCount = 0,
DateTime? memberLastPlayedDate = null,
long memberPositionTicks = 0)
{
var h = new Harness();
@@ -46,7 +48,9 @@ public class WatchedStateSyncTests
{
Key = "k",
Played = memberAlreadyPlayed,
PlayCount = memberPlayCount
PlayCount = memberPlayCount,
LastPlayedDate = memberLastPlayedDate,
PlaybackPositionTicks = memberPositionTicks
});
h.UserData.Setup(m => m.SaveUserData(
@@ -56,7 +60,8 @@ public class WatchedStateSyncTests
It.IsAny<UserDataSaveReason>(),
It.IsAny<CancellationToken>()))
.Callback<User, BaseItem, UserItemData, UserDataSaveReason, CancellationToken>(
(u, _, d, _, _) => h.Saves.Add((u, d.Played, d.PlayCount)));
(u, _, d, _, _) => h.Saves.Add(
new Save(u, d.Played, d.PlayCount, d.LastPlayedDate, d.PlaybackPositionTicks)));
h.Service = new WatchedStateSyncService(
h.UserData.Object,
@@ -70,7 +75,7 @@ public class WatchedStateSyncTests
/// <summary>
/// Raises UserDataSaved as the server would, by starting the service so it subscribes.
/// </summary>
public void Raise(Guid userId, bool played, UserDataSaveReason reason)
public void Raise(Guid userId, bool played, UserDataSaveReason reason, DateTime? lastPlayedDate = null)
{
Service.StartAsync(CancellationToken.None).GetAwaiter().GetResult();
@@ -80,7 +85,7 @@ public class WatchedStateSyncTests
{
UserId = userId,
Item = new Folder { Name = "Some Item" },
UserData = new UserItemData { Key = "k", Played = played },
UserData = new UserItemData { Key = "k", Played = played, LastPlayedDate = lastPlayedDate },
SaveReason = reason
});
@@ -88,6 +93,14 @@ public class WatchedStateSyncTests
}
}
/// <summary>
/// A snapshot of one member row as it was handed to SaveUserData. The service reuses the
/// object it got from GetUserData, so the fields are copied rather than the reference kept.
/// </summary>
private sealed record Save(User Member, bool Played, int PlayCount, DateTime? LastPlayedDate, long PositionTicks);
private static readonly DateTime SharedWatchedAt = new(2026, 9, 18, 21, 30, 0, DateTimeKind.Utc);
private static User MakeUser(string name) => new(name, "Prov", "ResetProv");
private static SharedGroup MakeGroup(bool syncUnwatched = true, bool syncPlayCount = false)
@@ -126,11 +139,10 @@ public class WatchedStateSyncTests
[Theory]
[InlineData(UserDataSaveReason.PlaybackStart)]
[InlineData(UserDataSaveReason.PlaybackProgress)]
[InlineData(UserDataSaveReason.UpdateUserRating)]
public void IrrelevantSaveReasons_AreIgnored(UserDataSaveReason reason)
{
// UserDataSaved fires constantly during playback; only watched-state changes matter.
// Neither of these ever carries a watched-state change, whatever the flag says.
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
h.Raise(SharedId, true, reason);
@@ -138,6 +150,100 @@ public class WatchedStateSyncTests
Assert.Empty(h.Saves);
}
[Theory]
[InlineData(UserDataSaveReason.PlaybackFinished)]
[InlineData(UserDataSaveReason.PlaybackProgress)]
[InlineData(UserDataSaveReason.UpdateUserData)]
public void PlaybackDerivedReasons_SyncWatched(UserDataSaveReason reason)
{
// Progress crosses the completion threshold before the stop arrives, and some clients
// never send a stop at all, so the tick has to land on the progress tick too.
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
h.Raise(SharedId, true, reason);
Assert.Single(h.Saves);
Assert.True(h.Saves[0].Played);
}
[Theory]
[InlineData(UserDataSaveReason.PlaybackFinished)]
[InlineData(UserDataSaveReason.PlaybackProgress)]
[InlineData(UserDataSaveReason.UpdateUserData)]
public void PlaybackDerivedReasons_NeverSyncUnwatched(UserDataSaveReason reason)
{
// PlaybackFinished fires on every stop, not just on completion, and on 10.11 PlaybackStart
// resets Played to false first. Stopping halfway through on the shared account must not
// clear what a member watched on their own - even with Sync unwatched on.
var h = Harness.Create(MakeGroup(syncUnwatched: true), [MakeUser("alice")], memberAlreadyPlayed: true);
h.Raise(SharedId, false, reason);
Assert.Empty(h.Saves);
}
[Fact]
public void Played_WritesWhatMarkPlayedWrites()
{
// Next Up is driven by LastPlayedDate on the member's own row, and a stale resume position
// would keep the item in Continue Watching, so the tick alone is not enough.
var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberPositionTicks: 12_345);
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished, SharedWatchedAt);
var save = Assert.Single(h.Saves);
Assert.True(save.Played);
Assert.Equal(SharedWatchedAt, save.LastPlayedDate);
Assert.Equal(0, save.PositionTicks);
}
[Fact]
public void Played_FallsBackToNowWhenSharedAccountHasNoDate()
{
var before = DateTime.UtcNow;
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
h.Raise(SharedId, true, UserDataSaveReason.TogglePlayed);
var save = Assert.Single(h.Saves);
Assert.NotNull(save.LastPlayedDate);
Assert.InRange(save.LastPlayedDate!.Value, before, DateTime.UtcNow);
}
[Fact]
public void Played_RepairsAnAlreadyTickedRowThatHasNoDate()
{
// Rows synced by earlier versions have the tick but no date. They are not "already in
// sync": without a date the member's Next Up never moves.
var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberAlreadyPlayed: true, memberLastPlayedDate: null);
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished, SharedWatchedAt);
var save = Assert.Single(h.Saves);
Assert.True(save.Played);
Assert.Equal(SharedWatchedAt, save.LastPlayedDate);
}
[Fact]
public void Unwatched_WritesWhatMarkUnplayedWrites()
{
var h = Harness.Create(
MakeGroup(syncUnwatched: true),
[MakeUser("alice")],
memberAlreadyPlayed: true,
memberPlayCount: 3,
memberLastPlayedDate: SharedWatchedAt,
memberPositionTicks: 999);
h.Raise(SharedId, false, UserDataSaveReason.TogglePlayed);
var save = Assert.Single(h.Saves);
Assert.False(save.Played);
Assert.Null(save.LastPlayedDate);
Assert.Equal(0, save.PositionTicks);
Assert.Equal(3, save.PlayCount);
}
[Fact]
public void Unwatched_PropagatesWhenSyncUnwatchedEnabled()
{
@@ -159,13 +265,20 @@ public class WatchedStateSyncTests
Assert.Empty(h.Saves);
}
[Fact]
public void RedundantWrites_AreSuppressed()
[Theory]
[InlineData(UserDataSaveReason.PlaybackFinished)]
[InlineData(UserDataSaveReason.PlaybackProgress)]
public void RedundantWrites_AreSuppressed(UserDataSaveReason reason)
{
// The member already matches the shared account, so there is nothing to write.
var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberAlreadyPlayed: true);
// The member already matches the shared account, so there is nothing to write. This is
// what keeps the stream of progress ticks after the completion threshold write-free.
var h = Harness.Create(
MakeGroup(),
[MakeUser("alice")],
memberAlreadyPlayed: true,
memberLastPlayedDate: SharedWatchedAt);
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished);
h.Raise(SharedId, true, reason);
Assert.Empty(h.Saves);
}
@@ -24,30 +24,48 @@ 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 Services.IGroupService _groupService;
private readonly Services.IDynamicGroupService _dynamicGroupService;
private readonly Lazy<Services.IGroupService> _groupService;
private readonly Lazy<Services.IDynamicGroupService> _dynamicGroupService;
private readonly Lazy<Services.IRestrictionService> _restrictionService;
private readonly ILogger<SharedAccountAuthenticationProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SharedAccountAuthenticationProvider"/> class.
/// </summary>
/// <param name="cryptoProvider">The crypto provider used to verify stored password hashes.</param>
/// <param name="groupService">The group service.</param>
/// <param name="dynamicGroupService">The on-demand group creation service.</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.
/// Jellyfin's <c>UserManager</c> constructor-injects every <see cref="IAuthenticationProvider"/>,
/// so resolving those services eagerly here would require <c>IUserManager</c> while it is still
/// being built and the host would refuse to start. Deferring the lookup to the first
/// authentication is safe: nobody can log in until the host is fully up.
/// </remarks>
public SharedAccountAuthenticationProvider(
ICryptoProvider cryptoProvider,
Services.IGroupService groupService,
Services.IDynamicGroupService dynamicGroupService,
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;
}
@@ -73,7 +91,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
// a real user with that exact name always resolves first and never reaches this branch.
if (resolvedUser is null)
{
var created = await _dynamicGroupService
var created = await _dynamicGroupService.Value
.TryCreateFromLoginAsync(username, password)
.ConfigureAwait(false);
@@ -85,7 +103,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
return new ProviderAuthenticationResult { Username = created.SharedUsername };
}
var group = _groupService.GetGroupForSharedUser(resolvedUser.Id);
var group = _groupService.Value.GetGroupForSharedUser(resolvedUser.Id);
if (group is null)
{
// Either not one of ours, or the group is disabled. Either way this account has no
@@ -96,7 +114,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
throw new AuthenticationException("Invalid username or password.");
}
var members = _groupService.GetEligibleMembers(group);
var members = _groupService.Value.GetEligibleMembers(group);
if (members.Count == 0)
{
_logger.LogWarning(
@@ -112,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,
@@ -129,12 +158,15 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
throw new AuthenticationException("Invalid username or password.");
}
#if !JELLYFIN_12
/// <inheritdoc />
/// <remarks>
/// A shared account always has a password in the sense that matters to Jellyfin: some member
/// credential is required. Returning <c>false</c> would let clients offer a passwordless login.
/// Jellyfin 12 removed this hook from the provider contract along with passwordless logins.
/// </remarks>
public bool HasPassword(User user) => true;
#endif
/// <inheritdoc />
/// <remarks>
@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Library;
namespace Jellyfin.Plugin.WatchedTogether.Compat;
/// <summary>
/// Papers over the <see cref="IUserManager"/> differences between Jellyfin 10.11 and 12.
/// </summary>
/// <remarks>
/// <para>
/// Jellyfin 12 turned the <c>Users</c> and <c>UsersIds</c> properties into methods and made
/// <c>ChangePassword</c> take a user id instead of a user. The plugin is compiled once per server
/// generation (see the project file), and this is the only place that needs to know which one it
/// is building for, so the rest of the code reads the same either way.
/// </para>
/// <para>
/// Jellyfin 12 also dropped the in-memory user cache: every lookup returns a detached copy, and
/// <see cref="IUserManager.UpdateUserAsync"/> copies every column from the instance it is given.
/// Callers that mutate a user and save it must therefore work with a fresh copy, which is why
/// <see cref="ChangePasswordAsync"/> carries the stored hash back onto the caller's instance.
/// </para>
/// </remarks>
internal static class UserManagerCompat
{
/// <summary>
/// Gets every user on the server.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <returns>All users.</returns>
public static IEnumerable<User> GetAllUsers(this IUserManager userManager)
{
ArgumentNullException.ThrowIfNull(userManager);
#if JELLYFIN_12
return userManager.GetUsers();
#else
return userManager.Users;
#endif
}
/// <summary>
/// Gets the id of every user on the server.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <returns>All user ids.</returns>
public static IEnumerable<Guid> GetAllUserIds(this IUserManager userManager)
{
ArgumentNullException.ThrowIfNull(userManager);
#if JELLYFIN_12
return userManager.GetUsersIds();
#else
return userManager.UsersIds;
#endif
}
/// <summary>
/// Changes a user's password through the provider the user is currently assigned to.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <param name="user">The user whose password to change.</param>
/// <param name="newPassword">The new password.</param>
/// <returns>A task representing the change.</returns>
/// <remarks>
/// On return <paramref name="user"/> carries the newly stored hash on both server generations,
/// so a subsequent <see cref="IUserManager.UpdateUserAsync"/> with the same instance keeps it.
/// </remarks>
public static async Task ChangePasswordAsync(this IUserManager userManager, User user, string newPassword)
{
ArgumentNullException.ThrowIfNull(userManager);
ArgumentNullException.ThrowIfNull(user);
#if JELLYFIN_12
await userManager.ChangePassword(user.Id, newPassword).ConfigureAwait(false);
// 12 loaded and saved its own copy; without this the caller's instance still says "no
// password" and the next UpdateUserAsync would write that back over the stored hash.
var stored = userManager.GetUserById(user.Id);
if (stored is not null)
{
user.Password = stored.Password;
}
#else
await userManager.ChangePassword(user, newPassword).ConfigureAwait(false);
#endif
}
}
@@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 += ' &mdash; inherited from the strictest member. Every member can unlock the account.';
} else {
text += ' &mdash; 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(' &middot; ') : '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') +
' &middot; 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 || '+';
@@ -3,6 +3,8 @@ using System.Collections.Generic;
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;
@@ -24,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;
@@ -31,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;
}
@@ -57,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>
@@ -86,7 +78,7 @@ public class WatchedTogetherController : ControllerBase
.Select(g => g.SharedUserId)
.ToHashSet() ?? [];
var users = _userManager.Users
var users = _userManager.GetAllUsers()
.Where(u => !sharedIds.Contains(u.Id))
.Select(u => new MemberDto { UserId = u.Id, Username = u.Username })
.OrderBy(u => u.Username, StringComparer.OrdinalIgnoreCase)
@@ -113,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)
{
@@ -156,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();
}
@@ -191,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
};
}
@@ -1,7 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
One source tree, two Jellyfin generations. Jellyfin 10.11 runs on .NET 9 and Jellyfin 12 on
.NET 10, and 12 changed a handful of IUserManager signatures, so the plugin is built once per
target framework against the matching server packages. The JELLYFIN_12 constant guards the few
call sites that differ; see Compat/UserManagerCompat.cs.
-->
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFrameworks>net9.0;net10.0</TargetFrameworks>
<RootNamespace>Jellyfin.Plugin.WatchedTogether</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@@ -10,7 +16,11 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0'">
<DefineConstants>$(DefineConstants);JELLYFIN_12</DefineConstants>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="Jellyfin.Controller" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
@@ -19,6 +29,15 @@
</PackageReference>
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Jellyfin.Controller" Version="12.0.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="12.0.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
@@ -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; }
}
@@ -1,3 +1,4 @@
using System;
using Jellyfin.Plugin.WatchedTogether.Auth;
using Jellyfin.Plugin.WatchedTogether.Services;
using MediaBrowser.Controller;
@@ -17,9 +18,20 @@ public class ServiceRegistrator : IPluginServiceRegistrator
{
serviceCollection.AddSingleton<IGroupService, GroupService>();
serviceCollection.AddSingleton<ILibraryAccessService, LibraryAccessService>();
serviceCollection.AddSingleton<IRestrictionService, RestrictionService>();
serviceCollection.AddSingleton<IProvisioningService, ProvisioningService>();
serviceCollection.AddSingleton<IDynamicGroupService, DynamicGroupService>();
// The auth provider takes these lazily so the container can build it while IUserManager is
// still under construction; see SharedAccountAuthenticationProvider's constructor remarks.
// Microsoft's container has no built-in Lazy<T> support, so the factories are explicit.
serviceCollection.AddSingleton(
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);
@@ -6,6 +6,7 @@ using System.Security.Cryptography;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.WatchedTogether.Compat;
using Jellyfin.Plugin.WatchedTogether.Configuration;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
@@ -32,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>
@@ -39,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;
}
@@ -100,25 +105,35 @@ public class ProvisioningService : IProvisioningService
var sharedUser = await _userManager.CreateUserAsync(accountName).ConfigureAwait(false);
// The shared account never authenticates against its own password - our provider checks
// member hashes instead. Setting a random one avoids leaving a passwordless account behind
// if the provider is ever unassigned.
//
// This must happen before the account is claimed below. IUserManager.ChangePassword
// dispatches to the provider the user is currently assigned to, and ours refuses the call
// by design, so claiming first would make provisioning throw NotSupportedException. A
// freshly created user is still on Jellyfin's default provider, which stores the hash.
// The compat wrapper also keeps sharedUser in step with what was stored, which matters on
// Jellyfin 12 where UpdateUserAsync below would otherwise overwrite the hash with null.
await _userManager.ChangePasswordAsync(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
// Route this account's logins through our provider. Jellyfin matches providers by
// GetType().FullName, the same key the SSO plugin uses, and the assignment only sticks
// once the user is updated.
sharedUser.AuthenticationProviderId = AuthProviderId;
// The shared account never authenticates against its own password - our provider checks
// member hashes instead. Setting a random one avoids leaving a passwordless account behind
// if the provider is ever unassigned.
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
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);
@@ -137,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);
@@ -180,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;
}
@@ -258,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));
}
}
@@ -2,6 +2,7 @@ using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.WatchedTogether.Compat;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -22,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>
@@ -30,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;
}
@@ -49,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)
@@ -60,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);
}
}
@@ -98,7 +116,7 @@ public sealed class UserLifecycleService : IHostedService
return;
}
var liveIds = _userManager.UsersIds.ToHashSet();
var liveIds = _userManager.GetAllUserIds().ToHashSet();
var referenced = config.Groups
.SelectMany(g => g.MemberUserIds.Append(g.SharedUserId))
@@ -70,9 +70,17 @@ public sealed class WatchedStateSyncService : IHostedService, IDisposable
/// Mirrors a shared account's played state onto its members.
/// </summary>
/// <remarks>
/// <para>
/// No loop guard is needed. Writing to a member raises this event again with that member's id,
/// which is not a shared account id, so the handler returns immediately. The
/// <c>Played</c> equality check below suppresses redundant writes on top of that.
/// </para>
/// <para>
/// A member is written the same way Jellyfin's own <c>BaseItem.MarkPlayed</c> and
/// <c>MarkUnplayed</c> write, not just the <c>Played</c> flag. Next Up is driven entirely by
/// <c>LastPlayedDate</c> on the member's own row, so a tick without a date leaves the member's
/// Next Up stuck; and a stale resume position would keep the item in Continue Watching.
/// </para>
/// </remarks>
private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
{
@@ -81,11 +89,8 @@ public sealed class WatchedStateSyncService : IHostedService, IDisposable
return;
}
// UserDataSaved fires constantly during playback (progress ticks); only act on the reasons
// that actually represent a change in watched state.
if (e.SaveReason is not (UserDataSaveReason.PlaybackFinished
or UserDataSaveReason.TogglePlayed
or UserDataSaveReason.Import))
var played = e.UserData.Played;
if (!IsWatchedStateChange(e.SaveReason, played))
{
return;
}
@@ -96,7 +101,6 @@ public sealed class WatchedStateSyncService : IHostedService, IDisposable
return;
}
var played = e.UserData.Played;
if (!played && !group.SyncUnwatched)
{
return;
@@ -107,16 +111,36 @@ public sealed class WatchedStateSyncService : IHostedService, IDisposable
try
{
var data = _userDataManager.GetUserData(member, e.Item);
if (data is null || data.Played == played)
if (data is null)
{
continue;
}
data.Played = played;
if (group.SyncPlayCount && played && data.PlayCount < 1)
// Rows written by earlier versions carry the tick but no date; give those a date
// the next time the item syncs rather than skipping them as already in sync.
if (data.Played == played && (!played || data.LastPlayedDate.HasValue))
{
data.PlayCount = 1;
continue;
}
if (played)
{
data.Played = true;
data.PlaybackPositionTicks = 0;
data.LastPlayedDate = e.UserData.LastPlayedDate ?? DateTime.UtcNow;
if (group.SyncPlayCount && data.PlayCount < 1)
{
data.PlayCount = 1;
}
}
else
{
// Same as Jellyfin's MarkUnplayed, except the play count is left alone: it is
// documented as never decreasing.
data.Played = false;
data.PlaybackPositionTicks = 0;
data.LastPlayedDate = null;
}
_userDataManager.SaveUserData(
@@ -144,4 +168,42 @@ public sealed class WatchedStateSyncService : IHostedService, IDisposable
}
}
}
/// <summary>
/// Decides whether a save represents a change in watched state worth mirroring.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="UserDataSaveReason.TogglePlayed"/> and <see cref="UserDataSaveReason.Import"/>
/// are explicit: someone set the flag, so whatever it says is mirrored, unwatched included.
/// </para>
/// <para>
/// The rest only ever mean "watched" when <paramref name="played"/> is true. Jellyfin raises
/// <see cref="UserDataSaveReason.PlaybackFinished"/> on every stop, not just on completion, so
/// a stop before the completion threshold leaves <c>Played</c> false without anybody having
/// marked anything unwatched - and on 10.11 <c>PlaybackStart</c> resets it to false as well.
/// Mirroring that would clear members' own watched state. Likewise
/// <see cref="UserDataSaveReason.UpdateUserData"/> is a partial update (a favourite toggle
/// arrives with the same reason) where a false flag need not mean a change at all.
/// </para>
/// <para>
/// <see cref="UserDataSaveReason.PlaybackProgress"/> is included so the tick lands as soon as
/// the completion threshold is crossed, and still lands for clients that never report a stop.
/// The equality check in the handler keeps the remaining progress ticks write-free.
/// </para>
/// </remarks>
/// <param name="reason">Why the user data was saved.</param>
/// <param name="played">The played flag on the saved user data.</param>
/// <returns><c>true</c> if the save should be mirrored to members.</returns>
private static bool IsWatchedStateChange(UserDataSaveReason reason, bool played)
{
return reason switch
{
UserDataSaveReason.TogglePlayed or UserDataSaveReason.Import => true,
UserDataSaveReason.PlaybackFinished
or UserDataSaveReason.PlaybackProgress
or UserDataSaveReason.UpdateUserData => played,
_ => false,
};
}
}
+104 -23
View File
@@ -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,11 +144,52 @@ 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 filters tightly: only `PlaybackFinished`,
`TogglePlayed` and `Import` are acted on, so the constant stream of progress updates during playback
is ignored.
The plugin subscribes to `UserDataSaved` and decides per save reason what it means:
- `TogglePlayed` and `Import` are explicit - someone set the flag - so whatever it says is mirrored,
unwatched included (subject to *Sync unwatched*).
- `PlaybackFinished`, `PlaybackProgress` and `UpdateUserData` only ever mirror *watched*. Jellyfin
raises `PlaybackFinished` on every stop, not just on completion, so a stop halfway through leaves
`Played` false without anyone having marked anything unwatched; propagating that would wipe what a
member watched on their own. Acting on progress too means the tick lands the moment the completion
threshold is crossed, and still lands for clients that never report a stop.
- Everything else (`PlaybackStart`, `UpdateUserRating`) is ignored.
A member is written the way Jellyfin's own *mark played* writes - `Played`, `LastPlayedDate` and a
cleared resume position - not just the flag. *Next Up* is computed from `LastPlayedDate` on the
member's own row, so a bare tick would leave their Next Up stuck on the wrong episode. Members whose
row already matches are skipped, which keeps the progress ticks after the threshold write-free.
No feedback loop is possible: writing to a member raises the event again with *that member's* ID,
which is not a shared account, so the handler stops immediately.
@@ -164,8 +210,10 @@ Then install **Watched Together** from the catalogue and restart Jellyfin.
### Manual
Download the release `.zip`, extract it into a `WatchedTogether` folder inside your Jellyfin
`plugins` directory, and restart the server.
Each release ships two `.zip` files: one for Jellyfin **10.11** and one for Jellyfin **12** (the
version's last segment says which: `x.y.z.11` or `x.y.z.12`). Download the one matching your
server, extract it into a `WatchedTogether` folder inside your Jellyfin `plugins` directory, and
restart the server. The plugin repository picks the right one for you automatically.
---
@@ -200,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
@@ -224,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
@@ -239,18 +301,26 @@ How this plugin bounds what a shared account can reach.
## Compatibility
| | |
| --- | --- |
| Target ABI | Jellyfin **10.11.x** |
| Framework | .NET 9 |
| Jellyfin | Framework | Built against | Package version |
| --- | --- | --- | --- |
| **10.11.x** | .NET 9 | `Jellyfin.Controller` 10.11.5 | `x.y.z.11` |
| **12.x** | .NET 10 | `Jellyfin.Controller` 12.0.0 | `x.y.z.12` |
Verified against the 10.11.5 SDK: `IAuthenticationProvider` + `IRequiresResolvedUser`,
`ICryptoProvider.Verify`, `IUserDataManager.UserDataSaved`, and a 255-character username limit.
One source tree, one build per server generation. Jellyfin 12 turned `IUserManager.Users` and
`UsersIds` into methods, made `ChangePassword` take a user id, dropped `HasPassword` from
`IAuthenticationProvider`, and stopped caching users in memory (every lookup is a detached copy).
Those differences live behind a `JELLYFIN_12` compile constant in
[Compat/UserManagerCompat.cs](Jellyfin.Plugin.WatchedTogether/Compat/UserManagerCompat.cs); the
rest of the plugin is identical on both.
Jellyfin installs the highest manifest version whose `targetAbi` it satisfies, which is why the two
packages of a release carry different version numbers: a 10.11 server only sees the `.11` entry,
while a 12 server sees both and takes the `.12` one.
Auto-creation depends on `UserManager.AuthenticateUser` offering unmatched usernames to every
enabled provider and re-querying the database afterwards ("the authentication provider might have
created it"). That behaviour is present in 10.11.5; if a future release changes it, auto-creation
stops working and dashboard provisioning continues to.
created it"). That behaviour is present in both 10.11.5 and 12.0; if a future release changes it,
auto-creation stops working and dashboard provisioning continues to.
Jellyfin's plugin API changes across minor versions, `IServerEntryPoint` gave way to
`IHostedService` around 10.9, and entity types moved namespaces in 10.11. Expect to rebuild against
@@ -260,26 +330,35 @@ the matching SDK when upgrading the server.
## Building
The plugin targets .NET 9. If your machine does not have that runtime, build in a container:
The solution multi-targets `net9.0` (Jellyfin 10.11) and `net10.0` (Jellyfin 12), so it needs the
.NET 10 SDK, which builds both. The test host rolls the `net9.0` run forward onto the .NET 10
runtime, so a single runtime is enough. If your machine does not have it, build in a container:
```bash
docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:9.0 \
docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:10.0 \
dotnet test Jellyfin.Plugin.WatchedTogether.sln -c Release
```
Or natively, with the .NET 9 SDK installed:
Or natively, with the .NET 10 SDK installed:
```bash
dotnet build Jellyfin.Plugin.WatchedTogether.sln -c Release
dotnet test Jellyfin.Plugin.WatchedTogether.sln -c Release
```
To produce an installable plugin zip:
Tests run once per framework; the `net10.0` run has one test fewer because `HasPassword` no longer
exists on Jellyfin 12's provider contract.
To produce installable plugin zips, one per Jellyfin generation:
```bash
jprm plugin build .
scripts/package.sh 10.11 0.0.5.11 # -> artifacts/watched-together_0.0.5.11.zip
scripts/package.sh 12 0.0.5.12 # -> artifacts/watched-together_0.0.5.12.zip
```
The script wraps `jprm plugin build`, stamping `build.yaml` with the right framework and
`targetAbi` for the chosen generation and restoring it afterwards.
### CI
Gitea Actions workflows live in [.gitea/workflows/](.gitea/workflows/):
@@ -287,8 +366,10 @@ Gitea Actions workflows live in [.gitea/workflows/](.gitea/workflows/):
| Workflow | Trigger | Does |
| --- | --- | --- |
| `test.yaml` | push / PR | Debug build and test run, uploads `.trx` results |
| `build.yaml` | push / PR to `master` | Release build, tests, and a date-versioned plugin zip |
| `release.yaml` | tag `v*.*.*` | Builds, creates a Gitea release, and updates `manifest.json` |
| `build.yaml` | push / PR to `master` | Release build, tests, and a date-versioned plugin zip per Jellyfin generation |
| `release.yaml` | tag `vX.Y.Z` | Builds `X.Y.Z.11` and `X.Y.Z.12`, creates a Gitea release with both, and adds both to `manifest.json` |
Release tags are three-part (`v0.0.5`); the fourth segment is reserved for the Jellyfin generation.
All three run in the builder image defined by [Dockerfile.builder](Dockerfile.builder):
+8 -5
View File
@@ -1,7 +1,9 @@
---
name: "Watched Together"
guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b"
version: "0.0.2.0"
version: "0.1.0.0"
# The plugin ships one package per Jellyfin generation. These defaults describe the 10.11 package;
# scripts/package.sh stamps targetAbi/framework for whichever generation it is asked to build.
targetAbi: "10.11.0.0"
framework: "net9.0"
overview: "One shared login for several people; watched state flows back to each member's own account"
@@ -26,7 +28,8 @@ dotnet_framework: "net9.0"
# Point at the plugin project rather than the solution so the test project is not packaged.
project: "Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj"
changelog: >
Member order no longer matters when resolving a group: "john+jane" and
"jane+john" are the same account instead of creating a second one. Account
names are sorted alphabetically, and a member's password is checked in the
order the names were typed.
Shared accounts now inherit their members' parental restrictions, strictest wins:
rating cap, unrated-item block, blocked and allowed tags. A per-group slider on the
plugin page raises the rating cap anywhere up to the loosest member's; members stricter
than the chosen cap can then no longer unlock the account with their password. Existing
shared accounts are tightened to their strictest member on first start after upgrading.
+212
View File
@@ -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.
+3
View File
@@ -100,6 +100,9 @@
<Rule Id="CA1308" Action="None" />
<!-- disable warning CA1848: Use the LoggerMessage delegates -->
<Rule Id="CA1848" Action="None" />
<!-- disable warning CA1873: Avoid potentially expensive logging (.NET 10 analyzer; same
call as CA1848, and the upstream Jellyfin 12 tree keeps it at suggestion level) -->
<Rule Id="CA1873" Action="Info" />
<!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments -->
<Rule Id="CA2101" Action="None" />
<!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
+40
View File
@@ -7,6 +7,46 @@
"owner": "dtourolle",
"category": "General",
"versions": [
{
"version": "0.0.5.12",
"changelog": "Release 0.0.5.12 (Jellyfin 12)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.5/watched-together_0.0.5.12.zip",
"checksum": "a1058821e7e218a2426699e9459ae6ae",
"timestamp": "2026-09-11T17:32:25Z"
},
{
"version": "0.0.5.11",
"changelog": "Release 0.0.5.11 (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.5/watched-together_0.0.5.11.zip",
"checksum": "0cbc0bf4352142fe7ccf303cfc31c896",
"timestamp": "2026-09-11T17:32:25Z"
},
{
"version": "0.0.4.0",
"changelog": "Release 0.0.4.0",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.4.0/watched-together_0.0.4.0.zip",
"checksum": "f537c5305ac6fcb19024471968759bb5",
"timestamp": "2026-08-09T08:59:11Z"
},
{
"version": "0.0.3.0",
"changelog": "Release 0.0.3.0",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.3.0/watched-together_0.0.3.0.zip",
"checksum": "56aa6c2e8f12d7404889de8bc253eae3",
"timestamp": "2026-08-09T08:49:27Z"
},
{
"version": "0.0.2",
"changelog": "Release 0.0.2",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.2/watched-together_0.0.2.0.zip",
"checksum": "0fedb68a0910414518d7dc45f05fd78f",
"timestamp": "2026-07-31T07:45:08Z"
},
{
"version": "0.0.1",
"changelog": "Release 0.0.1",
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
#
# Packages the plugin for one Jellyfin generation with jprm.
#
# scripts/package.sh <10.11|12> <version> [output-dir]
#
# The plugin is built once per generation: Jellyfin 10.11 loads a net9.0 assembly and Jellyfin 12 a
# net10.0 one, and each package's meta.json must carry the matching targetAbi or the server refuses
# to load it. jprm takes the framework on the command line but reads targetAbi from build.yaml, so
# this stamps build.yaml for the chosen generation and restores it afterwards.
#
# Jellyfin installs the highest manifest version whose targetAbi it satisfies, so when both packages
# are published from one release the 12 package must carry the higher version number. The release
# workflow does that by reserving the fourth version segment for the generation (X.Y.Z.11 and
# X.Y.Z.12).
set -euo pipefail
target="${1:?usage: $0 <10.11|12> <version> [output-dir]}"
version="${2:?usage: $0 <10.11|12> <version> [output-dir]}"
output="${3:-artifacts}"
case "$target" in
10.11) framework="net9.0"; target_abi="10.11.0.0" ;;
12) framework="net10.0"; target_abi="12.0.0.0" ;;
*) echo "unknown Jellyfin target '$target' (expected 10.11 or 12)" >&2; exit 2 ;;
esac
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
cp build.yaml build.yaml.orig
trap 'mv build.yaml.orig build.yaml' EXIT
sed -i \
-e "s/^version:.*/version: \"${version}\"/" \
-e "s/^targetAbi:.*/targetAbi: \"${target_abi}\"/" \
-e "s/^framework:.*/framework: \"${framework}\"/" \
-e "s/^dotnet_framework:.*/dotnet_framework: \"${framework}\"/" \
build.yaml
mkdir -p "$output"
jprm --verbosity=debug plugin build . \
--output "$output" \
--version "$version" \
--dotnet-framework "$framework"