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"