Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5397017000 | ||
|
|
bd08629fff | ||
|
|
17bb9a1e8a |
+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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.0.4.0</Version>
|
||||
<AssemblyVersion>0.0.4.0</AssemblyVersion>
|
||||
<FileVersion>0.0.4.0</FileVersion>
|
||||
<Version>0.0.5.0</Version>
|
||||
<AssemblyVersion>0.0.5.0</AssemblyVersion>
|
||||
<FileVersion>0.0.5.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
+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):
|
||||
|
||||
|
||||
+6
-7
@@ -1,7 +1,9 @@
|
||||
---
|
||||
name: "Watched Together"
|
||||
guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b"
|
||||
version: "0.0.4.0"
|
||||
version: "0.0.5.0"
|
||||
# The plugin ships one package per Jellyfin generation. These defaults describe the 10.11 package;
|
||||
# scripts/package.sh stamps targetAbi/framework for whichever generation it is asked to build.
|
||||
targetAbi: "10.11.0.0"
|
||||
framework: "net9.0"
|
||||
overview: "One shared login for several people; watched state flows back to each member's own account"
|
||||
@@ -26,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 -->
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
"owner": "dtourolle",
|
||||
"category": "General",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.0.4.0",
|
||||
"changelog": "Release 0.0.4.0",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.4.0/watched-together_0.0.4.0.zip",
|
||||
"checksum": "f537c5305ac6fcb19024471968759bb5",
|
||||
"timestamp": "2026-08-09T08:59:11Z"
|
||||
},
|
||||
{
|
||||
"version": "0.0.3.0",
|
||||
"changelog": "Release 0.0.3.0",
|
||||
|
||||
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