From bd08629fff5478b29428b67ac7637840302c12e4 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 11 Sep 2026 19:25:16 +0200 Subject: [PATCH] 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) --- .gitea/workflows/build.yaml | 29 +++-- .gitea/workflows/release.yaml | 104 +++++++++--------- Dockerfile.builder | 6 +- .../AuthenticationTests.cs | 3 + ...llyfin.Plugin.WatchedTogether.Tests.csproj | 30 +++-- .../ProvisioningTests.cs | 68 +++++++++++- .../SharedAccountEndToEndTests.cs | 3 +- .../UserManagerMockExtensions.cs | 41 +++++++ .../SharedAccountAuthenticationProvider.cs | 3 + .../Compat/UserManagerCompat.cs | 87 +++++++++++++++ .../Controllers/WatchedTogetherController.cs | 3 +- .../Jellyfin.Plugin.WatchedTogether.csproj | 23 +++- .../Services/ProvisioningService.cs | 5 +- .../Services/UserLifecycleService.cs | 3 +- README.md | 55 ++++++--- build.yaml | 11 +- jellyfin.ruleset | 3 + scripts/package.sh | 45 ++++++++ 18 files changed, 411 insertions(+), 111 deletions(-) create mode 100644 Jellyfin.Plugin.WatchedTogether.Tests/UserManagerMockExtensions.cs create mode 100644 Jellyfin.Plugin.WatchedTogether/Compat/UserManagerCompat.cs create mode 100755 scripts/package.sh diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 7461def..4668b07 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -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 diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index f7f2dd4..af6f580 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -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 < 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 diff --git a/Dockerfile.builder b/Dockerfile.builder index d16e240..b0d6bc6 100644 --- a/Dockerfile.builder +++ b/Dockerfile.builder @@ -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 diff --git a/Jellyfin.Plugin.WatchedTogether.Tests/AuthenticationTests.cs b/Jellyfin.Plugin.WatchedTogether.Tests/AuthenticationTests.cs index 6c3ccea..806c35b 100644 --- a/Jellyfin.Plugin.WatchedTogether.Tests/AuthenticationTests.cs +++ b/Jellyfin.Plugin.WatchedTogether.Tests/AuthenticationTests.cs @@ -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() diff --git a/Jellyfin.Plugin.WatchedTogether.Tests/Jellyfin.Plugin.WatchedTogether.Tests.csproj b/Jellyfin.Plugin.WatchedTogether.Tests/Jellyfin.Plugin.WatchedTogether.Tests.csproj index ad25d5a..6f215e9 100644 --- a/Jellyfin.Plugin.WatchedTogether.Tests/Jellyfin.Plugin.WatchedTogether.Tests.csproj +++ b/Jellyfin.Plugin.WatchedTogether.Tests/Jellyfin.Plugin.WatchedTogether.Tests.csproj @@ -1,7 +1,8 @@ - net9.0 + + net9.0;net10.0 enable false @@ -10,13 +11,17 @@ false $(NoWarn);CA1707;SA0001;CS1591 LatestMajor + + $(DefineConstants);JELLYFIN_12 + + @@ -28,14 +33,19 @@ - - + + + + + + + diff --git a/Jellyfin.Plugin.WatchedTogether.Tests/ProvisioningTests.cs b/Jellyfin.Plugin.WatchedTogether.Tests/ProvisioningTests.cs index b6d9b22..8a2e938 100644 --- a/Jellyfin.Plugin.WatchedTogether.Tests/ProvisioningTests.cs +++ b/Jellyfin.Plugin.WatchedTogether.Tests/ProvisioningTests.cs @@ -43,8 +43,7 @@ public class ProvisioningTests return created; }); - userManager.Setup(m => m.ChangePassword(It.IsAny(), It.IsAny())) - .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 { MakeUser("alice"), MakeUser("bob") }; + var userManager = new Mock(); + + userManager.Setup(m => m.GetUserById(It.IsAny())) + .Returns((Guid id) => Copy(stored.Find(u => u.Id == id))); + + userManager.Setup(m => m.CreateUserAsync(It.IsAny())) + .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())) + .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"); + } + + /// + /// Copies the columns provisioning touches into a fresh instance with the same id, the way a + /// cache-less user manager hands out rows. + /// + private static User? Copy(User? row) + => row is null + ? null + : new User(row.Username, row.AuthenticationProviderId, row.PasswordResetProviderId) + { + Id = row.Id, + Password = row.Password, + }; } diff --git a/Jellyfin.Plugin.WatchedTogether.Tests/SharedAccountEndToEndTests.cs b/Jellyfin.Plugin.WatchedTogether.Tests/SharedAccountEndToEndTests.cs index c771dc9..8c3dffe 100644 --- a/Jellyfin.Plugin.WatchedTogether.Tests/SharedAccountEndToEndTests.cs +++ b/Jellyfin.Plugin.WatchedTogether.Tests/SharedAccountEndToEndTests.cs @@ -64,8 +64,7 @@ public class SharedAccountEndToEndTests userManager.Setup(m => m.UpdateUserAsync(It.IsAny())).Returns(Task.CompletedTask); - userManager.Setup(m => m.ChangePassword(It.IsAny(), It.IsAny())) - .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)) diff --git a/Jellyfin.Plugin.WatchedTogether.Tests/UserManagerMockExtensions.cs b/Jellyfin.Plugin.WatchedTogether.Tests/UserManagerMockExtensions.cs new file mode 100644 index 0000000..07a636f --- /dev/null +++ b/Jellyfin.Plugin.WatchedTogether.Tests/UserManagerMockExtensions.cs @@ -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; + +/// +/// Sets up the members whose signatures differ between Jellyfin 10.11 +/// and 12, so the suites read the same whichever generation they are compiled against. +/// +internal static class UserManagerMockExtensions +{ + /// + /// Routes ChangePassword to 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. + /// + /// The user manager mock. + /// The users the mock knows about, used to resolve ids on 12. + /// The behaviour to run for the change. + public static void SetupChangePassword( + this Mock mock, + List users, + Func onChange) + { +#if JELLYFIN_12 + mock.Setup(m => m.ChangePassword(It.IsAny(), It.IsAny())) + .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(), It.IsAny())) + .Returns((User user, string password) => onChange(user, password)); +#endif + } +} diff --git a/Jellyfin.Plugin.WatchedTogether/Auth/SharedAccountAuthenticationProvider.cs b/Jellyfin.Plugin.WatchedTogether/Auth/SharedAccountAuthenticationProvider.cs index 8a97f1b..0011357 100644 --- a/Jellyfin.Plugin.WatchedTogether/Auth/SharedAccountAuthenticationProvider.cs +++ b/Jellyfin.Plugin.WatchedTogether/Auth/SharedAccountAuthenticationProvider.cs @@ -136,12 +136,15 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq throw new AuthenticationException("Invalid username or password."); } +#if !JELLYFIN_12 /// /// /// A shared account always has a password in the sense that matters to Jellyfin: some member /// credential is required. Returning false would let clients offer a passwordless login. + /// Jellyfin 12 removed this hook from the provider contract along with passwordless logins. /// public bool HasPassword(User user) => true; +#endif /// /// diff --git a/Jellyfin.Plugin.WatchedTogether/Compat/UserManagerCompat.cs b/Jellyfin.Plugin.WatchedTogether/Compat/UserManagerCompat.cs new file mode 100644 index 0000000..1940623 --- /dev/null +++ b/Jellyfin.Plugin.WatchedTogether/Compat/UserManagerCompat.cs @@ -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; + +/// +/// Papers over the differences between Jellyfin 10.11 and 12. +/// +/// +/// +/// Jellyfin 12 turned the Users and UsersIds properties into methods and made +/// ChangePassword 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. +/// +/// +/// Jellyfin 12 also dropped the in-memory user cache: every lookup returns a detached copy, and +/// 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 +/// carries the stored hash back onto the caller's instance. +/// +/// +internal static class UserManagerCompat +{ + /// + /// Gets every user on the server. + /// + /// The user manager. + /// All users. + public static IEnumerable GetAllUsers(this IUserManager userManager) + { + ArgumentNullException.ThrowIfNull(userManager); +#if JELLYFIN_12 + return userManager.GetUsers(); +#else + return userManager.Users; +#endif + } + + /// + /// Gets the id of every user on the server. + /// + /// The user manager. + /// All user ids. + public static IEnumerable GetAllUserIds(this IUserManager userManager) + { + ArgumentNullException.ThrowIfNull(userManager); +#if JELLYFIN_12 + return userManager.GetUsersIds(); +#else + return userManager.UsersIds; +#endif + } + + /// + /// Changes a user's password through the provider the user is currently assigned to. + /// + /// The user manager. + /// The user whose password to change. + /// The new password. + /// A task representing the change. + /// + /// On return carries the newly stored hash on both server generations, + /// so a subsequent with the same instance keeps it. + /// + 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 + } +} diff --git a/Jellyfin.Plugin.WatchedTogether/Controllers/WatchedTogetherController.cs b/Jellyfin.Plugin.WatchedTogether/Controllers/WatchedTogetherController.cs index 62fe988..afd994a 100644 --- a/Jellyfin.Plugin.WatchedTogether/Controllers/WatchedTogetherController.cs +++ b/Jellyfin.Plugin.WatchedTogether/Controllers/WatchedTogetherController.cs @@ -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) diff --git a/Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj b/Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj index 825f125..0408c0b 100644 --- a/Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj +++ b/Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj @@ -1,7 +1,13 @@ + - net9.0 + net9.0;net10.0 Jellyfin.Plugin.WatchedTogether true true @@ -10,7 +16,11 @@ ../jellyfin.ruleset - + + $(DefineConstants);JELLYFIN_12 + + + runtime @@ -19,6 +29,15 @@ + + + runtime + + + runtime + + + diff --git a/Jellyfin.Plugin.WatchedTogether/Services/ProvisioningService.cs b/Jellyfin.Plugin.WatchedTogether/Services/ProvisioningService.cs index 7093587..74ac2f5 100644 --- a/Jellyfin.Plugin.WatchedTogether/Services/ProvisioningService.cs +++ b/Jellyfin.Plugin.WatchedTogether/Services/ProvisioningService.cs @@ -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 diff --git a/Jellyfin.Plugin.WatchedTogether/Services/UserLifecycleService.cs b/Jellyfin.Plugin.WatchedTogether/Services/UserLifecycleService.cs index 7281c45..c05f3c4 100644 --- a/Jellyfin.Plugin.WatchedTogether/Services/UserLifecycleService.cs +++ b/Jellyfin.Plugin.WatchedTogether/Services/UserLifecycleService.cs @@ -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)) diff --git a/README.md b/README.md index 973654d..72deef2 100644 --- a/README.md +++ b/README.md @@ -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): diff --git a/build.yaml b/build.yaml index 78c08bf..9762259 100644 --- a/build.yaml +++ b/build.yaml @@ -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. diff --git a/jellyfin.ruleset b/jellyfin.ruleset index 8af791c..459ffab 100644 --- a/jellyfin.ruleset +++ b/jellyfin.ruleset @@ -100,6 +100,9 @@ + + diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 0000000..54da5c6 --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# Packages the plugin for one Jellyfin generation with jprm. +# +# scripts/package.sh <10.11|12> [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> [output-dir]}" +version="${2:?usage: $0 <10.11|12> [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"