9 Commits
Author SHA1 Message Date
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
22 changed files with 887 additions and 124 deletions
+17 -12
View File
@@ -64,23 +64,28 @@ jobs:
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.WatchedTogether.sln --no-build --configuration Release --verbosity normal 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: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: | run: |
mkdir -p artifacts # One package per Jellyfin generation. Both carry the same date-based version, so they
jprm --verbosity=debug plugin build . # go to separate directories; the meta.json targetAbi inside each tells them apart.
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||') scripts/package.sh 10.11 "${{ steps.version.outputs.version }}" artifacts/jellyfin-10.11
LATEST="artifacts/watchedtogether_latest.zip" scripts/package.sh 12 "${{ steps.version.outputs.version }}" artifacts/jellyfin-12
cp "${ARTIFACT}" "${LATEST}" find artifacts -name "*.zip" -type f
echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
- name: Upload build artifact - name: Upload Jellyfin 10.11 package
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: watchedtogether-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }} name: watchedtogether-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}-jellyfin-10.11
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }} 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 retention-days: 30
if-no-files-found: error if-no-files-found: error
+49 -55
View File
@@ -31,16 +31,15 @@ jobs:
else else
VERSION="${GITHUB_REF#refs/tags/}" VERSION="${GITHUB_REF#refs/tags/}"
fi 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=${VERSION}" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT echo "version_number=${BASE}" >> $GITHUB_OUTPUT
echo "Building version: ${VERSION}" echo "version_1011=${BASE}.11" >> $GITHUB_OUTPUT
echo "version_12=${BASE}.12" >> $GITHUB_OUTPUT
- name: Update build.yaml with version echo "Building version: ${VERSION} (${BASE}.11 for Jellyfin 10.11, ${BASE}.12 for Jellyfin 12)"
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
- name: Cache NuGet packages - name: Cache NuGet packages
uses: actions/cache@v3 uses: actions/cache@v3
@@ -61,25 +60,24 @@ jobs:
working-directory: release-${{ github.run_id }} working-directory: release-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.WatchedTogether.sln --no-build --configuration Release --verbosity normal 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 id: jprm
working-directory: release-${{ github.run_id }} working-directory: release-${{ github.run_id }}
run: | run: |
mkdir -p artifacts scripts/package.sh 10.11 "${{ steps.get_version.outputs.version_1011 }}" artifacts
jprm --verbosity=debug plugin build ./ scripts/package.sh 12 "${{ steps.get_version.outputs.version_12 }}" artifacts
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}"
- name: Calculate checksum ARTIFACT_1011=$(find artifacts -name "*_${{ steps.get_version.outputs.version_1011 }}.zip" -type f -print -quit)
id: checksum ARTIFACT_12=$(find artifacts -name "*_${{ steps.get_version.outputs.version_12 }}.zip" -type f -print -quit)
working-directory: release-${{ github.run_id }} test -n "${ARTIFACT_1011}" && test -n "${ARTIFACT_12}"
run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}') echo "artifact_1011=${ARTIFACT_1011}" >> $GITHUB_OUTPUT
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT echo "artifact_name_1011=$(basename "${ARTIFACT_1011}")" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}" 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 - name: Create Release
working-directory: release-${{ github.run_id }} working-directory: release-${{ github.run_id }}
@@ -95,7 +93,7 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \ "${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) HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d') BODY=$(echo "$RESPONSE" | sed '$d')
@@ -109,19 +107,14 @@ jobs:
exit 1 exit 1
fi fi
echo "Uploading plugin artifact..." for ARTIFACT in "${{ steps.jprm.outputs.artifact_1011 }}" "${{ steps.jprm.outputs.artifact_12 }}"; do
curl -f -X POST \ echo "Uploading ${ARTIFACT}..."
-H "Authorization: token ${GITEA_TOKEN}" \ curl -f -X POST \
-H "Content-Type: application/zip" \ -H "Authorization: token ${GITEA_TOKEN}" \
--data-binary "@${{ steps.jprm.outputs.artifact }}" \ -H "Content-Type: application/zip" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}" --data-binary "@${ARTIFACT}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ARTIFACT}")"
echo "Uploading build.yaml..." done
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"
echo "✅ Release created successfully!" echo "✅ Release created successfully!"
@@ -133,32 +126,33 @@ jobs:
REPO_OWNER="${{ github.repository_owner }}" REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}" REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}" 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") 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.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris" git config user.email "actions@gitea.tourolle.paris"
git fetch origin master git fetch origin master
git checkout master git checkout master
NEW_VERSION=$(cat <<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
"version": "${VERSION}", # while 12 sees both and takes the .12 one.
"changelog": "Release ${VERSION}", NEW_VERSIONS=$(jq -n \
"targetAbi": "10.11.0.0", --arg ts "${TIMESTAMP}" \
"sourceUrl": "${DOWNLOAD_URL}", --arg v12 "${{ steps.get_version.outputs.version_12 }}" \
"checksum": "${CHECKSUM}", --arg url12 "${DOWNLOAD_BASE}/${{ steps.jprm.outputs.artifact_name_12 }}" \
"timestamp": "${TIMESTAMP}" --arg sum12 "${{ steps.jprm.outputs.checksum_12 }}" \
} --arg v1011 "${{ steps.get_version.outputs.version_1011 }}" \
EOF --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 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 git push origin master
- name: Cleanup - name: Cleanup
+3 -3
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.0.2.0</Version> <Version>0.0.5.0</Version>
<AssemblyVersion>0.0.2.0</AssemblyVersion> <AssemblyVersion>0.0.5.0</AssemblyVersion>
<FileVersion>0.0.2.0</FileVersion> <FileVersion>0.0.5.0</FileVersion>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+4 -2
View File
@@ -1,9 +1,11 @@
# Watched Together builder image # 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 . # Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/watchedtogether-builder:latest .
# Push: docker push 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 # 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 # actions/cache are JavaScript actions, and the runner execs `node` inside this
@@ -63,8 +63,8 @@ public class AuthenticationTests
return new SharedAccountAuthenticationProvider( return new SharedAccountAuthenticationProvider(
crypto, crypto,
groups.Object, new Lazy<IGroupService>(() => groups.Object),
dynamic.Object, new Lazy<IDynamicGroupService>(() => dynamic.Object),
NullLogger<SharedAccountAuthenticationProvider>.Instance); NullLogger<SharedAccountAuthenticationProvider>.Instance);
} }
@@ -198,13 +198,16 @@ public class AuthenticationTests
Assert.Equal("alice+bob", result.Username); Assert.Equal("alice+bob", result.Username);
} }
#if !JELLYFIN_12
[Fact] [Fact]
public void HasPassword_IsAlwaysTrue() public void HasPassword_IsAlwaysTrue()
{ {
// Returning false would let a client offer a passwordless login for the shared account. // 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, []); var provider = MakeProvider(null, []);
Assert.True(provider.HasPassword(MakeUser("alice+bob", null))); Assert.True(provider.HasPassword(MakeUser("alice+bob", null)));
} }
#endif
[Fact] [Fact]
public async Task ChangePassword_IsNotSupported() public async Task ChangePassword_IsNotSupported()
@@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <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> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<!-- Test code is exempt from the strict analyzer profile the plugin itself uses. --> <!-- Test code is exempt from the strict analyzer profile the plugin itself uses. -->
@@ -10,13 +11,17 @@
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CA1707;SA0001;CS1591</NoWarn> <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 The net9.0 build matches Jellyfin 10.11's ABI, but a machine may only have a newer runtime
runtime installed. Rolling the test host forward to the latest major lets the suite run installed. Rolling the test host forward to the latest major lets the suite run without
without pinning developers to a .NET 9 runtime. pinning developers to a .NET 9 runtime.
--> -->
<RollForward>LatestMajor</RollForward> <RollForward>LatestMajor</RollForward>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0'">
<DefineConstants>$(DefineConstants);JELLYFIN_12</DefineConstants>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2" />
@@ -28,14 +33,19 @@
<ProjectReference Include="../Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj" /> <ProjectReference Include="../Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <!--
<!-- The plugin excludes the runtime assets of these packages because the Jellyfin server supplies
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
them at load time. Tests run without a server, so they need the real assemblies copied to the output directory.
output directory. -->
--> <ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" /> <PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
<PackageReference Include="Jellyfin.Model" Version="10.11.5" /> <PackageReference Include="Jellyfin.Model" Version="10.11.5" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
<PackageReference Include="Jellyfin.Model" Version="12.0.0" />
</ItemGroup>
</Project> </Project>
@@ -0,0 +1,215 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
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>()),
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)
=> new(
userManager.Object,
Mock.Of<ILibraryAccessService>(),
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,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,159 @@
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.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);
var provisioning = new ProvisioningService(
userManager.Object,
Mock.Of<ILibraryAccessService>(),
NullLogger<ProvisioningService>.Instance);
var dynamicGroups = new DynamicGroupService(
userManager.Object,
provisioning,
crypto,
NullLogger<DynamicGroupService>.Instance);
var provider = new SharedAccountAuthenticationProvider(
crypto,
new Lazy<IGroupService>(() => groupService),
new Lazy<IDynamicGroupService>(() => dynamicGroups),
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,41 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Library;
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
}
}
@@ -28,21 +28,28 @@ namespace Jellyfin.Plugin.WatchedTogether.Auth;
public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser
{ {
private readonly ICryptoProvider _cryptoProvider; private readonly ICryptoProvider _cryptoProvider;
private readonly Services.IGroupService _groupService; private readonly Lazy<Services.IGroupService> _groupService;
private readonly Services.IDynamicGroupService _dynamicGroupService; private readonly Lazy<Services.IDynamicGroupService> _dynamicGroupService;
private readonly ILogger<SharedAccountAuthenticationProvider> _logger; private readonly ILogger<SharedAccountAuthenticationProvider> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SharedAccountAuthenticationProvider"/> class. /// Initializes a new instance of the <see cref="SharedAccountAuthenticationProvider"/> class.
/// </summary> /// </summary>
/// <param name="cryptoProvider">The crypto provider used to verify stored password hashes.</param> /// <param name="cryptoProvider">The crypto provider used to verify stored password hashes.</param>
/// <param name="groupService">The group service.</param> /// <param name="groupService">A deferred handle to the group service.</param>
/// <param name="dynamicGroupService">The on-demand group creation service.</param> /// <param name="dynamicGroupService">A deferred handle to the on-demand group creation service.</param>
/// <param name="logger">The logger.</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( public SharedAccountAuthenticationProvider(
ICryptoProvider cryptoProvider, ICryptoProvider cryptoProvider,
Services.IGroupService groupService, Lazy<Services.IGroupService> groupService,
Services.IDynamicGroupService dynamicGroupService, Lazy<Services.IDynamicGroupService> dynamicGroupService,
ILogger<SharedAccountAuthenticationProvider> logger) ILogger<SharedAccountAuthenticationProvider> logger)
{ {
_cryptoProvider = cryptoProvider; _cryptoProvider = cryptoProvider;
@@ -73,7 +80,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
// a real user with that exact name always resolves first and never reaches this branch. // a real user with that exact name always resolves first and never reaches this branch.
if (resolvedUser is null) if (resolvedUser is null)
{ {
var created = await _dynamicGroupService var created = await _dynamicGroupService.Value
.TryCreateFromLoginAsync(username, password) .TryCreateFromLoginAsync(username, password)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -85,7 +92,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
return new ProviderAuthenticationResult { Username = created.SharedUsername }; return new ProviderAuthenticationResult { Username = created.SharedUsername };
} }
var group = _groupService.GetGroupForSharedUser(resolvedUser.Id); var group = _groupService.Value.GetGroupForSharedUser(resolvedUser.Id);
if (group is null) if (group is null)
{ {
// Either not one of ours, or the group is disabled. Either way this account has no // Either not one of ours, or the group is disabled. Either way this account has no
@@ -96,7 +103,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
throw new AuthenticationException("Invalid username or password."); throw new AuthenticationException("Invalid username or password.");
} }
var members = _groupService.GetEligibleMembers(group); var members = _groupService.Value.GetEligibleMembers(group);
if (members.Count == 0) if (members.Count == 0)
{ {
_logger.LogWarning( _logger.LogWarning(
@@ -129,12 +136,15 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
throw new AuthenticationException("Invalid username or password."); throw new AuthenticationException("Invalid username or password.");
} }
#if !JELLYFIN_12
/// <inheritdoc /> /// <inheritdoc />
/// <remarks> /// <remarks>
/// A shared account always has a password in the sense that matters to Jellyfin: some member /// 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. /// 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> /// </remarks>
public bool HasPassword(User user) => true; public bool HasPassword(User user) => true;
#endif
/// <inheritdoc /> /// <inheritdoc />
/// <remarks> /// <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
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Mime; using System.Net.Mime;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.WatchedTogether.Compat;
using Jellyfin.Plugin.WatchedTogether.Models; using Jellyfin.Plugin.WatchedTogether.Models;
using Jellyfin.Plugin.WatchedTogether.Services; using Jellyfin.Plugin.WatchedTogether.Services;
using MediaBrowser.Common.Api; using MediaBrowser.Common.Api;
@@ -86,7 +87,7 @@ public class WatchedTogetherController : ControllerBase
.Select(g => g.SharedUserId) .Select(g => g.SharedUserId)
.ToHashSet() ?? []; .ToHashSet() ?? [];
var users = _userManager.Users var users = _userManager.GetAllUsers()
.Where(u => !sharedIds.Contains(u.Id)) .Where(u => !sharedIds.Contains(u.Id))
.Select(u => new MemberDto { UserId = u.Id, Username = u.Username }) .Select(u => new MemberDto { UserId = u.Id, Username = u.Username })
.OrderBy(u => u.Username, StringComparer.OrdinalIgnoreCase) .OrderBy(u => u.Username, StringComparer.OrdinalIgnoreCase)
@@ -1,7 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk"> <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> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFrameworks>net9.0;net10.0</TargetFrameworks>
<RootNamespace>Jellyfin.Plugin.WatchedTogether</RootNamespace> <RootNamespace>Jellyfin.Plugin.WatchedTogether</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@@ -10,7 +16,11 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup> </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"> <PackageReference Include="Jellyfin.Controller" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets> <ExcludeAssets>runtime</ExcludeAssets>
</PackageReference> </PackageReference>
@@ -19,6 +29,15 @@
</PackageReference> </PackageReference>
</ItemGroup> </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> <ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
@@ -1,3 +1,4 @@
using System;
using Jellyfin.Plugin.WatchedTogether.Auth; using Jellyfin.Plugin.WatchedTogether.Auth;
using Jellyfin.Plugin.WatchedTogether.Services; using Jellyfin.Plugin.WatchedTogether.Services;
using MediaBrowser.Controller; using MediaBrowser.Controller;
@@ -20,6 +21,14 @@ public class ServiceRegistrator : IPluginServiceRegistrator
serviceCollection.AddSingleton<IProvisioningService, ProvisioningService>(); serviceCollection.AddSingleton<IProvisioningService, ProvisioningService>();
serviceCollection.AddSingleton<IDynamicGroupService, DynamicGroupService>(); 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>));
// Discovered by Jellyfin and matched to shared accounts via User.AuthenticationProviderId. // Discovered by Jellyfin and matched to shared accounts via User.AuthenticationProviderId.
serviceCollection.AddSingleton<IAuthenticationProvider, SharedAccountAuthenticationProvider>(); serviceCollection.AddSingleton<IAuthenticationProvider, SharedAccountAuthenticationProvider>();
@@ -6,6 +6,7 @@ using System.Security.Cryptography;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data; using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums; using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.WatchedTogether.Compat;
using Jellyfin.Plugin.WatchedTogether.Configuration; using Jellyfin.Plugin.WatchedTogether.Configuration;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -100,15 +101,22 @@ public class ProvisioningService : IProvisioningService
var sharedUser = await _userManager.CreateUserAsync(accountName).ConfigureAwait(false); 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 // 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 // GetType().FullName, the same key the SSO plugin uses, and the assignment only sticks
// once the user is updated. // once the user is updated.
sharedUser.AuthenticationProviderId = AuthProviderId; 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 _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false); await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false);
@@ -2,6 +2,7 @@ using System;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.WatchedTogether.Compat;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -98,7 +99,7 @@ public sealed class UserLifecycleService : IHostedService
return; return;
} }
var liveIds = _userManager.UsersIds.ToHashSet(); var liveIds = _userManager.GetAllUserIds().ToHashSet();
var referenced = config.Groups var referenced = config.Groups
.SelectMany(g => g.MemberUserIds.Append(g.SharedUserId)) .SelectMany(g => g.MemberUserIds.Append(g.SharedUserId))
+38 -17
View File
@@ -164,8 +164,10 @@ Then install **Watched Together** from the catalogue and restart Jellyfin.
### Manual ### Manual
Download the release `.zip`, extract it into a `WatchedTogether` folder inside your Jellyfin Each release ships two `.zip` files: one for Jellyfin **10.11** and one for Jellyfin **12** (the
`plugins` directory, and restart the server. 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.
--- ---
@@ -239,18 +241,26 @@ How this plugin bounds what a shared account can reach.
## Compatibility ## Compatibility
| | | | Jellyfin | Framework | Built against | Package version |
| --- | --- | | --- | --- | --- | --- |
| Target ABI | Jellyfin **10.11.x** | | **10.11.x** | .NET 9 | `Jellyfin.Controller` 10.11.5 | `x.y.z.11` |
| Framework | .NET 9 | | **12.x** | .NET 10 | `Jellyfin.Controller` 12.0.0 | `x.y.z.12` |
Verified against the 10.11.5 SDK: `IAuthenticationProvider` + `IRequiresResolvedUser`, One source tree, one build per server generation. Jellyfin 12 turned `IUserManager.Users` and
`ICryptoProvider.Verify`, `IUserDataManager.UserDataSaved`, and a 255-character username limit. `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 Auto-creation depends on `UserManager.AuthenticateUser` offering unmatched usernames to every
enabled provider and re-querying the database afterwards ("the authentication provider might have 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 created it"). That behaviour is present in both 10.11.5 and 12.0; if a future release changes it,
stops working and dashboard provisioning continues to. auto-creation stops working and dashboard provisioning continues to.
Jellyfin's plugin API changes across minor versions, `IServerEntryPoint` gave way 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 `IHostedService` around 10.9, and entity types moved namespaces in 10.11. Expect to rebuild against
@@ -260,26 +270,35 @@ the matching SDK when upgrading the server.
## Building ## 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 ```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 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 ```bash
dotnet build Jellyfin.Plugin.WatchedTogether.sln -c Release dotnet build Jellyfin.Plugin.WatchedTogether.sln -c Release
dotnet test 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 ```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 ### CI
Gitea Actions workflows live in [.gitea/workflows/](.gitea/workflows/): Gitea Actions workflows live in [.gitea/workflows/](.gitea/workflows/):
@@ -287,8 +306,10 @@ Gitea Actions workflows live in [.gitea/workflows/](.gitea/workflows/):
| Workflow | Trigger | Does | | Workflow | Trigger | Does |
| --- | --- | --- | | --- | --- | --- |
| `test.yaml` | push / PR | Debug build and test run, uploads `.trx` results | | `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 | | `build.yaml` | push / PR to `master` | Release build, tests, and a date-versioned plugin zip per Jellyfin generation |
| `release.yaml` | tag `v*.*.*` | Builds, creates a Gitea release, and updates `manifest.json` | | `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): All three run in the builder image defined by [Dockerfile.builder](Dockerfile.builder):
+6 -5
View File
@@ -1,7 +1,9 @@
--- ---
name: "Watched Together" name: "Watched Together"
guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b" guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b"
version: "0.0.2.0" version: "0.0.5.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" targetAbi: "10.11.0.0"
framework: "net9.0" framework: "net9.0"
overview: "One shared login for several people; watched state flows back to each member's own account" overview: "One shared login for several people; watched state flows back to each member's own account"
@@ -26,7 +28,6 @@ dotnet_framework: "net9.0"
# Point at the plugin project rather than the solution so the test project is not packaged. # Point at the plugin project rather than the solution so the test project is not packaged.
project: "Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj" project: "Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj"
changelog: > changelog: >
Member order no longer matters when resolving a group: "john+jane" and Adds support for Jellyfin 12 alongside 10.11. Each release now ships two packages:
"jane+john" are the same account instead of creating a second one. Account one built against 10.11 on .NET 9 and one against 12 on .NET 10, and the plugin
names are sorted alphabetically, and a member's password is checked in the repository serves whichever matches your server.
order the names were typed.
+3
View File
@@ -100,6 +100,9 @@
<Rule Id="CA1308" Action="None" /> <Rule Id="CA1308" Action="None" />
<!-- disable warning CA1848: Use the LoggerMessage delegates --> <!-- disable warning CA1848: Use the LoggerMessage delegates -->
<Rule Id="CA1848" Action="None" /> <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 --> <!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments -->
<Rule Id="CA2101" Action="None" /> <Rule Id="CA2101" Action="None" />
<!-- disable warning CA2234: Pass System.Uri objects instead of strings --> <!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
+24
View File
@@ -7,6 +7,30 @@
"owner": "dtourolle", "owner": "dtourolle",
"category": "General", "category": "General",
"versions": [ "versions": [
{
"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", "version": "0.0.1",
"changelog": "Release 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"