6 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
20 changed files with 747 additions and 114 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.3.0</Version>
<AssemblyVersion>0.0.3.0</AssemblyVersion>
<FileVersion>0.0.3.0</FileVersion>
<Version>0.0.5.0</Version>
<AssemblyVersion>0.0.5.0</AssemblyVersion>
<FileVersion>0.0.5.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
@@ -198,13 +198,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()
@@ -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,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,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
}
}
@@ -136,12 +136,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
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Net.Mime;
using System.Threading.Tasks;
using Jellyfin.Plugin.WatchedTogether.Compat;
using Jellyfin.Plugin.WatchedTogether.Models;
using Jellyfin.Plugin.WatchedTogether.Services;
using MediaBrowser.Common.Api;
@@ -86,7 +87,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)
@@ -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" />
@@ -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;
@@ -100,15 +101,22 @@ 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);
@@ -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;
@@ -98,7 +99,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))
+38 -17
View File
@@ -164,8 +164,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.
---
@@ -239,18 +241,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 +270,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 +306,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):
+6 -6
View File
@@ -1,7 +1,9 @@
---
name: "Watched Together"
guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b"
version: "0.0.3.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"
framework: "net9.0"
overview: "One shared login for several people; watched state flows back to each member's own account"
@@ -26,8 +28,6 @@ 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: >
Fixes a startup crash: installing 0.0.2 left the server unable to boot with
"a circular dependency was detected for the service of type IUserManager".
Jellyfin builds every authentication provider while constructing the user
manager, so the plugin's provider now resolves its group services on first
login instead of at construction time.
Adds support for Jellyfin 12 alongside 10.11. Each release now ships two packages:
one built against 10.11 on .NET 9 and one against 12 on .NET 10, and the plugin
repository serves whichever matches your server.
+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 -->
+16
View File
@@ -7,6 +7,22 @@
"owner": "dtourolle",
"category": "General",
"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",
+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"