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>
This commit is contained in:
+17
-12
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-2
@@ -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()
|
||||
|
||||
+20
-10
@@ -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>
|
||||
|
||||
@@ -43,8 +43,7 @@ public class ProvisioningTests
|
||||
return created;
|
||||
});
|
||||
|
||||
userManager.Setup(m => m.ChangePassword(It.IsAny<User>(), It.IsAny<string>()))
|
||||
.Returns((User user, string password) =>
|
||||
userManager.SetupChangePassword(users, (user, password) =>
|
||||
{
|
||||
callLog.Add($"ChangePassword(provider={user.AuthenticationProviderId})");
|
||||
|
||||
@@ -148,4 +147,69 @@ public class ProvisioningTests
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,8 +64,7 @@ public class SharedAccountEndToEndTests
|
||||
|
||||
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>())).Returns(Task.CompletedTask);
|
||||
|
||||
userManager.Setup(m => m.ChangePassword(It.IsAny<User>(), It.IsAny<string>()))
|
||||
.Returns((User user, string password) =>
|
||||
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))
|
||||
|
||||
@@ -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;
|
||||
@@ -108,7 +109,9 @@ public class ProvisioningService : IProvisioningService
|
||||
// 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.
|
||||
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
|
||||
// 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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
+5
-6
@@ -2,6 +2,8 @@
|
||||
name: "Watched Together"
|
||||
guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b"
|
||||
version: "0.0.4.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,9 +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 creating a group by typing "alice+bob" at the login screen, which failed
|
||||
with "a Watched Together shared account has no password of its own".
|
||||
Provisioning set the new account's placeholder password after routing it
|
||||
through this plugin's authentication provider, which refuses password changes
|
||||
by design; the password is now set while the account is still on Jellyfin's
|
||||
default provider.
|
||||
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.
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Executable
+45
@@ -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"
|
||||
Reference in New Issue
Block a user