Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5397017000 | ||
|
|
bd08629fff | ||
|
|
17bb9a1e8a | ||
|
|
44ab98e082 | ||
|
|
bb8814644c | ||
|
|
69f7a87cef | ||
|
|
fd08d7ea1a | ||
|
|
da779514fe | ||
|
|
dfab79e92a | ||
|
|
f430d288ea | ||
|
|
5c8430f207 | ||
|
|
cb95a317d0 |
+17
-12
@@ -64,23 +64,28 @@ jobs:
|
|||||||
working-directory: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
run: dotnet test Jellyfin.Plugin.WatchedTogether.sln --no-build --configuration Release --verbosity normal
|
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: build-${{ github.run_id }}
|
working-directory: build-${{ github.run_id }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
# One package per Jellyfin generation. Both carry the same date-based version, so they
|
||||||
jprm --verbosity=debug plugin build .
|
# go to separate directories; the meta.json targetAbi inside each tells them apart.
|
||||||
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
|
scripts/package.sh 10.11 "${{ steps.version.outputs.version }}" artifacts/jellyfin-10.11
|
||||||
LATEST="artifacts/watchedtogether_latest.zip"
|
scripts/package.sh 12 "${{ steps.version.outputs.version }}" artifacts/jellyfin-12
|
||||||
cp "${ARTIFACT}" "${LATEST}"
|
find artifacts -name "*.zip" -type f
|
||||||
echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
|
|
||||||
|
|
||||||
- name: Upload build artifact
|
- name: Upload Jellyfin 10.11 package
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: watchedtogether-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}
|
name: watchedtogether-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}-jellyfin-10.11
|
||||||
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
|
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
|
retention-days: 30
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
|||||||
@@ -31,16 +31,15 @@ jobs:
|
|||||||
else
|
else
|
||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
fi
|
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=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
echo "version_number=${BASE}" >> $GITHUB_OUTPUT
|
||||||
echo "Building version: ${VERSION}"
|
echo "version_1011=${BASE}.11" >> $GITHUB_OUTPUT
|
||||||
|
echo "version_12=${BASE}.12" >> $GITHUB_OUTPUT
|
||||||
- name: Update build.yaml with version
|
echo "Building version: ${VERSION} (${BASE}.11 for Jellyfin 10.11, ${BASE}.12 for Jellyfin 12)"
|
||||||
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
|
|
||||||
|
|
||||||
- name: Cache NuGet packages
|
- name: Cache NuGet packages
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -61,25 +60,24 @@ jobs:
|
|||||||
working-directory: release-${{ github.run_id }}
|
working-directory: release-${{ github.run_id }}
|
||||||
run: dotnet test Jellyfin.Plugin.WatchedTogether.sln --no-build --configuration Release --verbosity normal
|
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
|
id: jprm
|
||||||
working-directory: release-${{ github.run_id }}
|
working-directory: release-${{ github.run_id }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
scripts/package.sh 10.11 "${{ steps.get_version.outputs.version_1011 }}" artifacts
|
||||||
jprm --verbosity=debug plugin build ./
|
scripts/package.sh 12 "${{ steps.get_version.outputs.version_12 }}" artifacts
|
||||||
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}"
|
|
||||||
|
|
||||||
- name: Calculate checksum
|
ARTIFACT_1011=$(find artifacts -name "*_${{ steps.get_version.outputs.version_1011 }}.zip" -type f -print -quit)
|
||||||
id: checksum
|
ARTIFACT_12=$(find artifacts -name "*_${{ steps.get_version.outputs.version_12 }}.zip" -type f -print -quit)
|
||||||
working-directory: release-${{ github.run_id }}
|
test -n "${ARTIFACT_1011}" && test -n "${ARTIFACT_12}"
|
||||||
run: |
|
|
||||||
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
echo "artifact_1011=${ARTIFACT_1011}" >> $GITHUB_OUTPUT
|
||||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
echo "artifact_name_1011=$(basename "${ARTIFACT_1011}")" >> $GITHUB_OUTPUT
|
||||||
echo "Checksum: ${CHECKSUM}"
|
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
|
- name: Create Release
|
||||||
working-directory: release-${{ github.run_id }}
|
working-directory: release-${{ github.run_id }}
|
||||||
@@ -95,7 +93,7 @@ jobs:
|
|||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
"${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)
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
@@ -109,19 +107,14 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Uploading plugin artifact..."
|
for ARTIFACT in "${{ steps.jprm.outputs.artifact_1011 }}" "${{ steps.jprm.outputs.artifact_12 }}"; do
|
||||||
curl -f -X POST \
|
echo "Uploading ${ARTIFACT}..."
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
curl -f -X POST \
|
||||||
-H "Content-Type: application/zip" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
-H "Content-Type: application/zip" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
--data-binary "@${ARTIFACT}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ARTIFACT}")"
|
||||||
echo "Uploading build.yaml..."
|
done
|
||||||
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"
|
|
||||||
|
|
||||||
echo "✅ Release created successfully!"
|
echo "✅ Release created successfully!"
|
||||||
|
|
||||||
@@ -133,32 +126,33 @@ jobs:
|
|||||||
REPO_OWNER="${{ github.repository_owner }}"
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
GITEA_URL="${{ github.server_url }}"
|
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")
|
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.name "Gitea Actions"
|
||||||
git config user.email "actions@gitea.tourolle.paris"
|
git config user.email "actions@gitea.tourolle.paris"
|
||||||
git fetch origin master
|
git fetch origin master
|
||||||
git checkout master
|
git checkout master
|
||||||
|
|
||||||
NEW_VERSION=$(cat <<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
|
||||||
"version": "${VERSION}",
|
# while 12 sees both and takes the .12 one.
|
||||||
"changelog": "Release ${VERSION}",
|
NEW_VERSIONS=$(jq -n \
|
||||||
"targetAbi": "10.11.0.0",
|
--arg ts "${TIMESTAMP}" \
|
||||||
"sourceUrl": "${DOWNLOAD_URL}",
|
--arg v12 "${{ steps.get_version.outputs.version_12 }}" \
|
||||||
"checksum": "${CHECKSUM}",
|
--arg url12 "${DOWNLOAD_BASE}/${{ steps.jprm.outputs.artifact_name_12 }}" \
|
||||||
"timestamp": "${TIMESTAMP}"
|
--arg sum12 "${{ steps.jprm.outputs.checksum_12 }}" \
|
||||||
}
|
--arg v1011 "${{ steps.get_version.outputs.version_1011 }}" \
|
||||||
EOF
|
--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 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
|
git push origin master
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.0.1.0</Version>
|
<Version>0.0.5.0</Version>
|
||||||
<AssemblyVersion>0.0.1.0</AssemblyVersion>
|
<AssemblyVersion>0.0.5.0</AssemblyVersion>
|
||||||
<FileVersion>0.0.1.0</FileVersion>
|
<FileVersion>0.0.5.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+4
-2
@@ -1,9 +1,11 @@
|
|||||||
# Watched Together builder image
|
# 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 .
|
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/watchedtogether-builder:latest .
|
||||||
# Push: docker push 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
|
# 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
|
# actions/cache are JavaScript actions, and the runner execs `node` inside this
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ public class AuthenticationTests
|
|||||||
|
|
||||||
return new SharedAccountAuthenticationProvider(
|
return new SharedAccountAuthenticationProvider(
|
||||||
crypto,
|
crypto,
|
||||||
groups.Object,
|
new Lazy<IGroupService>(() => groups.Object),
|
||||||
dynamic.Object,
|
new Lazy<IDynamicGroupService>(() => dynamic.Object),
|
||||||
NullLogger<SharedAccountAuthenticationProvider>.Instance);
|
NullLogger<SharedAccountAuthenticationProvider>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,13 +198,16 @@ public class AuthenticationTests
|
|||||||
Assert.Equal("alice+bob", result.Username);
|
Assert.Equal("alice+bob", result.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !JELLYFIN_12
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HasPassword_IsAlwaysTrue()
|
public void HasPassword_IsAlwaysTrue()
|
||||||
{
|
{
|
||||||
// Returning false would let a client offer a passwordless login for the shared account.
|
// 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, []);
|
var provider = MakeProvider(null, []);
|
||||||
Assert.True(provider.HasPassword(MakeUser("alice+bob", null)));
|
Assert.True(provider.HasPassword(MakeUser("alice+bob", null)));
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ChangePassword_IsNotSupported()
|
public async Task ChangePassword_IsNotSupported()
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ public class DynamicGroupTests
|
|||||||
|
|
||||||
private sealed record Harness(
|
private sealed record Harness(
|
||||||
DynamicGroupService Service,
|
DynamicGroupService Service,
|
||||||
Mock<IProvisioningService> Provisioning);
|
Mock<IProvisioningService> Provisioning,
|
||||||
|
StubCryptoProvider Crypto);
|
||||||
|
|
||||||
private static Harness MakeService(
|
private static Harness MakeService(
|
||||||
IReadOnlyList<User> knownUsers,
|
IReadOnlyList<User> knownUsers,
|
||||||
@@ -67,6 +68,13 @@ public class DynamicGroupTests
|
|||||||
return null!;
|
return null!;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Any known user must also resolve by id, so an existing group can be followed back to its
|
||||||
|
// shared account.
|
||||||
|
foreach (var u in knownUsers)
|
||||||
|
{
|
||||||
|
userManager.Setup(m => m.GetUserById(u.Id)).Returns(u);
|
||||||
|
}
|
||||||
|
|
||||||
var provisioning = new Mock<IProvisioningService>();
|
var provisioning = new Mock<IProvisioningService>();
|
||||||
var createdShared = MakeUser("created-shared");
|
var createdShared = MakeUser("created-shared");
|
||||||
|
|
||||||
@@ -78,13 +86,15 @@ public class DynamicGroupTests
|
|||||||
|
|
||||||
userManager.Setup(m => m.GetUserById(createdShared.Id)).Returns(createdShared);
|
userManager.Setup(m => m.GetUserById(createdShared.Id)).Returns(createdShared);
|
||||||
|
|
||||||
|
var crypto = new StubCryptoProvider(validPairs);
|
||||||
|
|
||||||
var service = new DynamicGroupService(
|
var service = new DynamicGroupService(
|
||||||
userManager.Object,
|
userManager.Object,
|
||||||
provisioning.Object,
|
provisioning.Object,
|
||||||
new StubCryptoProvider(validPairs),
|
crypto,
|
||||||
NullLogger<DynamicGroupService>.Instance);
|
NullLogger<DynamicGroupService>.Instance);
|
||||||
|
|
||||||
return new Harness(service, provisioning);
|
return new Harness(service, provisioning, crypto);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -98,13 +108,108 @@ public class DynamicGroupTests
|
|||||||
var result = await h.Service.TryCreateFromLoginAsync("alice+bob", "alice-pw");
|
var result = await h.Service.TryCreateFromLoginAsync("alice+bob", "alice-pw");
|
||||||
|
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
|
// No name is passed: provisioning generates the canonical alphabetical one.
|
||||||
h.Provisioning.Verify(
|
h.Provisioning.Verify(
|
||||||
p => p.CreateGroupAsync(
|
p => p.CreateGroupAsync(
|
||||||
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 2),
|
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 2),
|
||||||
"alice+bob"),
|
null),
|
||||||
Times.Once);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReversedNameOrder_ReusesTheExistingGroup()
|
||||||
|
{
|
||||||
|
// "john+jane" and "jane+john" are the same group. Jellyfin only calls this code when no
|
||||||
|
// account matches the typed name, so without an order-independent lookup the reversed
|
||||||
|
// spelling would quietly create a second account for the same two people.
|
||||||
|
using var ctx = PluginTestContext.Create();
|
||||||
|
var alice = MakeUser("alice", AliceHash);
|
||||||
|
var bob = MakeUser("bob", BobHash);
|
||||||
|
var shared = MakeUser("alice+bob");
|
||||||
|
|
||||||
|
ctx.Configuration.Groups.Add(new SharedGroup
|
||||||
|
{
|
||||||
|
SharedUserId = shared.Id,
|
||||||
|
MemberUserIds = [alice.Id, bob.Id]
|
||||||
|
});
|
||||||
|
|
||||||
|
var h = MakeService([alice, bob, shared], (BobHash, "bob-pw"));
|
||||||
|
|
||||||
|
var result = await h.Service.TryCreateFromLoginAsync("bob+alice", "bob-pw");
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("alice+bob", result!.SharedUsername);
|
||||||
|
h.Provisioning.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ANewGroup_IsNamedCanonically()
|
||||||
|
{
|
||||||
|
// Provisioning is asked for no particular name so it generates the canonical sorted one,
|
||||||
|
// rather than preserving whatever order happened to be typed.
|
||||||
|
using var ctx = PluginTestContext.Create();
|
||||||
|
var alice = MakeUser("alice", AliceHash);
|
||||||
|
var bob = MakeUser("bob", BobHash);
|
||||||
|
var h = MakeService([alice, bob], (BobHash, "bob-pw"));
|
||||||
|
|
||||||
|
await h.Service.TryCreateFromLoginAsync("bob+alice", "bob-pw");
|
||||||
|
|
||||||
|
h.Provisioning.Verify(
|
||||||
|
p => p.CreateGroupAsync(It.IsAny<IReadOnlyList<Guid>>(), null),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReversedNameOrder_WhenTheGroupIsDisabled_IsRejected()
|
||||||
|
{
|
||||||
|
using var ctx = PluginTestContext.Create();
|
||||||
|
var alice = MakeUser("alice", AliceHash);
|
||||||
|
var bob = MakeUser("bob", BobHash);
|
||||||
|
var shared = MakeUser("alice+bob");
|
||||||
|
|
||||||
|
ctx.Configuration.Groups.Add(new SharedGroup
|
||||||
|
{
|
||||||
|
SharedUserId = shared.Id,
|
||||||
|
MemberUserIds = [alice.Id, bob.Id],
|
||||||
|
IsDisabled = true
|
||||||
|
});
|
||||||
|
|
||||||
|
var h = MakeService([alice, bob, shared], (BobHash, "bob-pw"));
|
||||||
|
|
||||||
|
Assert.Null(await h.Service.TryCreateFromLoginAsync("bob+alice", "bob-pw"));
|
||||||
|
h.Provisioning.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheFirstTypedMember_HasTheirPasswordCheckedFirst()
|
||||||
|
{
|
||||||
|
// Password verification is a deliberately slow hash comparison, so whoever puts their own
|
||||||
|
// name first should be checked first.
|
||||||
|
using var ctx = PluginTestContext.Create();
|
||||||
|
var alice = MakeUser("alice", AliceHash);
|
||||||
|
var bob = MakeUser("bob", BobHash);
|
||||||
|
var h = MakeService([alice, bob], (BobHash, "bob-pw"));
|
||||||
|
|
||||||
|
await h.Service.TryCreateFromLoginAsync("bob+alice", "bob-pw");
|
||||||
|
|
||||||
|
// Bob was typed first and his password matched, so alice's hash is never touched.
|
||||||
|
Assert.Equal(["B2B2B2B2"], h.Crypto.VerifiedSalts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ALaterMembersPassword_StillWorks()
|
||||||
|
{
|
||||||
|
// The first-typed member is only a preference: the second member's password must still
|
||||||
|
// unlock the group once the first fails to match.
|
||||||
|
using var ctx = PluginTestContext.Create();
|
||||||
|
var alice = MakeUser("alice", AliceHash);
|
||||||
|
var bob = MakeUser("bob", BobHash);
|
||||||
|
var h = MakeService([alice, bob], (BobHash, "bob-pw"));
|
||||||
|
|
||||||
|
Assert.NotNull(await h.Service.TryCreateFromLoginAsync("alice+bob", "bob-pw"));
|
||||||
|
Assert.Equal(["A1A1A1A1", "B2B2B2B2"], h.Crypto.VerifiedSalts);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnyNamedMembersPassword_Works()
|
public async Task AnyNamedMembersPassword_Works()
|
||||||
{
|
{
|
||||||
|
|||||||
+20
-10
@@ -1,7 +1,8 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<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>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<!-- Test code is exempt from the strict analyzer profile the plugin itself uses. -->
|
<!-- Test code is exempt from the strict analyzer profile the plugin itself uses. -->
|
||||||
@@ -10,13 +11,17 @@
|
|||||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CA1707;SA0001;CS1591</NoWarn>
|
<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
|
The net9.0 build matches Jellyfin 10.11's ABI, but a machine may only have a newer runtime
|
||||||
runtime installed. Rolling the test host forward to the latest major lets the suite run
|
installed. Rolling the test host forward to the latest major lets the suite run without
|
||||||
without pinning developers to a .NET 9 runtime.
|
pinning developers to a .NET 9 runtime.
|
||||||
-->
|
-->
|
||||||
<RollForward>LatestMajor</RollForward>
|
<RollForward>LatestMajor</RollForward>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||||
|
<DefineConstants>$(DefineConstants);JELLYFIN_12</DefineConstants>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
@@ -28,14 +33,19 @@
|
|||||||
<ProjectReference Include="../Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj" />
|
<ProjectReference Include="../Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<!--
|
||||||
<!--
|
The plugin excludes the runtime assets of these packages because the Jellyfin server supplies
|
||||||
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
|
||||||
them at load time. Tests run without a server, so they need the real assemblies copied to the
|
output directory.
|
||||||
output directory.
|
-->
|
||||||
-->
|
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
|
||||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
|
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
|
||||||
<PackageReference Include="Jellyfin.Model" Version="10.11.5" />
|
<PackageReference Include="Jellyfin.Model" Version="10.11.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||||
|
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
|
||||||
|
<PackageReference Include="Jellyfin.Model" Version="12.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers shared-account provisioning against a user manager that dispatches password changes the
|
||||||
|
/// way Jellyfin's real one does.
|
||||||
|
/// </summary>
|
||||||
|
[Collection(nameof(PluginTestContext))]
|
||||||
|
public class ProvisioningTests
|
||||||
|
{
|
||||||
|
private static readonly string AuthProviderId =
|
||||||
|
typeof(Auth.SharedAccountAuthenticationProvider).FullName!;
|
||||||
|
|
||||||
|
private static User MakeUser(string name) => new(name, "Prov", "ResetProv");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a user manager that mimics the one behaviour that matters here: ChangePassword is
|
||||||
|
/// routed to the provider named by the user's AuthenticationProviderId, so an account already
|
||||||
|
/// claimed by us lands in our provider and is refused.
|
||||||
|
/// </summary>
|
||||||
|
private static Mock<IUserManager> MakeUserManager(List<User> users, List<string> callLog)
|
||||||
|
{
|
||||||
|
var userManager = new Mock<IUserManager>();
|
||||||
|
|
||||||
|
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>()))
|
||||||
|
.Returns((Guid id) => users.Find(u => u.Id == id));
|
||||||
|
|
||||||
|
userManager.Setup(m => m.CreateUserAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync((string name) =>
|
||||||
|
{
|
||||||
|
var created = MakeUser(name);
|
||||||
|
users.Add(created);
|
||||||
|
callLog.Add("CreateUser");
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
userManager.SetupChangePassword(users, (user, password) =>
|
||||||
|
{
|
||||||
|
callLog.Add($"ChangePassword(provider={user.AuthenticationProviderId})");
|
||||||
|
|
||||||
|
// This is the dispatch that made provisioning fail in 0.0.3: once the account is
|
||||||
|
// claimed, the call reaches our provider, which refuses it by design.
|
||||||
|
if (string.Equals(user.AuthenticationProviderId, AuthProviderId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return new Auth.SharedAccountAuthenticationProvider(
|
||||||
|
Mock.Of<MediaBrowser.Model.Cryptography.ICryptoProvider>(),
|
||||||
|
new Lazy<IGroupService>(() => Mock.Of<IGroupService>()),
|
||||||
|
new Lazy<IDynamicGroupService>(() => Mock.Of<IDynamicGroupService>()),
|
||||||
|
NullLogger<Auth.SharedAccountAuthenticationProvider>.Instance)
|
||||||
|
.ChangePassword(user, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = password;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>()))
|
||||||
|
.Returns((User user) =>
|
||||||
|
{
|
||||||
|
callLog.Add($"UpdateUser(provider={user.AuthenticationProviderId})");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
return userManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProvisioningService MakeService(Mock<IUserManager> userManager)
|
||||||
|
=> new(
|
||||||
|
userManager.Object,
|
||||||
|
Mock.Of<ILibraryAccessService>(),
|
||||||
|
NullLogger<ProvisioningService>.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGroupAsync_SetsPasswordBeforeClaimingTheAccount()
|
||||||
|
{
|
||||||
|
using var context = PluginTestContext.Create();
|
||||||
|
|
||||||
|
var alice = MakeUser("alice");
|
||||||
|
var bob = MakeUser("bob");
|
||||||
|
var users = new List<User> { alice, bob };
|
||||||
|
var callLog = new List<string>();
|
||||||
|
|
||||||
|
var service = MakeService(MakeUserManager(users, callLog));
|
||||||
|
|
||||||
|
// Before the fix this threw NotSupportedException from our own ChangePassword.
|
||||||
|
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
|
||||||
|
|
||||||
|
Assert.NotEqual(Guid.Empty, group.SharedUserId);
|
||||||
|
|
||||||
|
// The password must be set while the account is still on Jellyfin's default provider.
|
||||||
|
var changeIndex = callLog.FindIndex(c => c.StartsWith("ChangePassword", StringComparison.Ordinal));
|
||||||
|
var claimIndex = callLog.FindIndex(c => c.Contains(AuthProviderId, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
Assert.True(changeIndex >= 0, "provisioning should set a password on the shared account");
|
||||||
|
Assert.True(claimIndex >= 0, "provisioning should claim the account for our provider");
|
||||||
|
Assert.True(
|
||||||
|
changeIndex < claimIndex,
|
||||||
|
$"password must be set before the account is claimed, but call order was: {string.Join(" -> ", callLog)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGroupAsync_LeavesTheAccountClaimedByOurProvider()
|
||||||
|
{
|
||||||
|
using var context = PluginTestContext.Create();
|
||||||
|
|
||||||
|
var alice = MakeUser("alice");
|
||||||
|
var bob = MakeUser("bob");
|
||||||
|
var users = new List<User> { alice, bob };
|
||||||
|
var callLog = new List<string>();
|
||||||
|
|
||||||
|
var service = MakeService(MakeUserManager(users, callLog));
|
||||||
|
|
||||||
|
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
|
||||||
|
|
||||||
|
// Claiming the account is what routes its logins to us; provisioning is useless without it.
|
||||||
|
var sharedUser = users.Find(u => u.Id == group.SharedUserId);
|
||||||
|
Assert.NotNull(sharedUser);
|
||||||
|
Assert.Equal(AuthProviderId, sharedUser!.AuthenticationProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGroupAsync_GivesTheSharedAccountANonEmptyPassword()
|
||||||
|
{
|
||||||
|
using var context = PluginTestContext.Create();
|
||||||
|
|
||||||
|
var alice = MakeUser("alice");
|
||||||
|
var bob = MakeUser("bob");
|
||||||
|
var users = new List<User> { alice, bob };
|
||||||
|
var callLog = new List<string>();
|
||||||
|
|
||||||
|
var service = MakeService(MakeUserManager(users, callLog));
|
||||||
|
|
||||||
|
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
|
||||||
|
|
||||||
|
// A passwordless shared account would be directly loginable if the provider were ever
|
||||||
|
// unassigned, which is the reason provisioning sets one at all.
|
||||||
|
var sharedUser = users.Find(u => u.Id == group.SharedUserId);
|
||||||
|
Assert.NotNull(sharedUser);
|
||||||
|
Assert.False(string.IsNullOrEmpty(sharedUser!.Password));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGroupAsync_StoredPasswordSurvivesClaimingTheAccount()
|
||||||
|
{
|
||||||
|
using var context = PluginTestContext.Create();
|
||||||
|
|
||||||
|
// Models Jellyfin 12, where the user manager keeps no cache: every lookup returns a
|
||||||
|
// detached copy of the stored row, and UpdateUserAsync writes back every column of the
|
||||||
|
// instance it is handed. The random password provisioning sets must survive the provider
|
||||||
|
// assignment that is saved afterwards through a different instance.
|
||||||
|
var stored = new List<User> { MakeUser("alice"), MakeUser("bob") };
|
||||||
|
var userManager = new Mock<IUserManager>();
|
||||||
|
|
||||||
|
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>()))
|
||||||
|
.Returns((Guid id) => Copy(stored.Find(u => u.Id == id)));
|
||||||
|
|
||||||
|
userManager.Setup(m => m.CreateUserAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync((string name) =>
|
||||||
|
{
|
||||||
|
var row = MakeUser(name);
|
||||||
|
stored.Add(row);
|
||||||
|
return Copy(row)!;
|
||||||
|
});
|
||||||
|
|
||||||
|
// On 12 the helper resolves the stored row by id, so this writes the hash there and only
|
||||||
|
// there; on 10.11 it writes to the caller's instance, as the real server does.
|
||||||
|
userManager.SetupChangePassword(stored, (user, password) =>
|
||||||
|
{
|
||||||
|
user.Password = password;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>()))
|
||||||
|
.Returns((User user) =>
|
||||||
|
{
|
||||||
|
var row = stored.Find(u => u.Id == user.Id)!;
|
||||||
|
row.Password = user.Password;
|
||||||
|
row.AuthenticationProviderId = user.AuthenticationProviderId;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
var service = MakeService(userManager);
|
||||||
|
|
||||||
|
var group = await service.CreateGroupAsync([stored[0].Id, stored[1].Id], null);
|
||||||
|
|
||||||
|
var row = stored.Find(u => u.Id == group.SharedUserId);
|
||||||
|
Assert.NotNull(row);
|
||||||
|
Assert.Equal(AuthProviderId, row!.AuthenticationProviderId);
|
||||||
|
Assert.False(
|
||||||
|
string.IsNullOrEmpty(row.Password),
|
||||||
|
"claiming the account must not overwrite the password provisioning stored");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies the columns provisioning touches into a fresh instance with the same id, the way a
|
||||||
|
/// cache-less user manager hands out rows.
|
||||||
|
/// </summary>
|
||||||
|
private static User? Copy(User? row)
|
||||||
|
=> row is null
|
||||||
|
? null
|
||||||
|
: new User(row.Username, row.AuthenticationProviderId, row.PasswordResetProviderId)
|
||||||
|
{
|
||||||
|
Id = row.Id,
|
||||||
|
Password = row.Password,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Jellyfin.Plugin.WatchedTogether;
|
||||||
|
using MediaBrowser.Controller.Authentication;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Cryptography;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards the plugin's service graph against container-level cycles.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Jellyfin's real <c>UserManager</c> constructor-injects <c>IEnumerable<IAuthenticationProvider></c>.
|
||||||
|
/// That means any plugin service reachable eagerly from our authentication provider must not itself
|
||||||
|
/// require <see cref="IUserManager"/>, or the host dies at startup with "a circular dependency was
|
||||||
|
/// detected". A cycle like that is invisible to unit tests that construct services by hand, so these
|
||||||
|
/// tests build the graph the way the host does.
|
||||||
|
/// </remarks>
|
||||||
|
public class ServiceRegistrationTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Stands in for Jellyfin's UserManager, whose constructor takes every registered authentication
|
||||||
|
/// provider. Only the constructor shape matters here - it is what closes the cycle.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class UserManagerWithAuthProviders
|
||||||
|
{
|
||||||
|
public UserManagerWithAuthProviders(IEnumerable<IAuthenticationProvider> authenticationProviders)
|
||||||
|
{
|
||||||
|
AuthenticationProviders = authenticationProviders;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<IAuthenticationProvider> AuthenticationProviders { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceProvider BuildHostLikeProvider()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
|
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||||
|
|
||||||
|
// Host services the plugin consumes, other than IUserManager.
|
||||||
|
services.AddSingleton(Mock.Of<ILibraryManager>());
|
||||||
|
services.AddSingleton(Mock.Of<IUserDataManager>());
|
||||||
|
services.AddSingleton(Mock.Of<ICryptoProvider>());
|
||||||
|
|
||||||
|
// IUserManager resolves through the fake UserManager so that building it forces every
|
||||||
|
// IAuthenticationProvider to be built first, exactly as the real host does.
|
||||||
|
services.AddSingleton<UserManagerWithAuthProviders>();
|
||||||
|
services.AddSingleton(provider =>
|
||||||
|
{
|
||||||
|
provider.GetRequiredService<UserManagerWithAuthProviders>();
|
||||||
|
return Mock.Of<IUserManager>();
|
||||||
|
});
|
||||||
|
|
||||||
|
new ServiceRegistrator().RegisterServices(services, Mock.Of<MediaBrowser.Controller.IServerApplicationHost>());
|
||||||
|
|
||||||
|
return services.BuildServiceProvider(new ServiceProviderOptions
|
||||||
|
{
|
||||||
|
ValidateOnBuild = true,
|
||||||
|
ValidateScopes = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PluginServices_ResolveWithoutCircularDependency()
|
||||||
|
{
|
||||||
|
using var provider = BuildHostLikeProvider();
|
||||||
|
|
||||||
|
// Resolving IUserManager is what the host does during startup, and is the exact path that
|
||||||
|
// previously threw InvalidOperationException for a circular dependency.
|
||||||
|
var userManager = provider.GetRequiredService<IUserManager>();
|
||||||
|
|
||||||
|
Assert.NotNull(userManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuthenticationProvider_IsConstructedWithoutResolvingUserManager()
|
||||||
|
{
|
||||||
|
using var provider = BuildHostLikeProvider();
|
||||||
|
|
||||||
|
var authProviders = provider.GetRequiredService<IEnumerable<IAuthenticationProvider>>();
|
||||||
|
|
||||||
|
Assert.Contains(authProviders, p => p is Auth.SharedAccountAuthenticationProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GroupServices_AreStillResolvableOnceTheHostIsUp()
|
||||||
|
{
|
||||||
|
using var provider = BuildHostLikeProvider();
|
||||||
|
|
||||||
|
// The Lazy<T> indirection must not change what the services resolve to at authentication
|
||||||
|
// time, and must hand back the same singletons the rest of the plugin uses.
|
||||||
|
var lazyGroupService = provider.GetRequiredService<Lazy<Services.IGroupService>>();
|
||||||
|
var lazyDynamicGroupService = provider.GetRequiredService<Lazy<Services.IDynamicGroupService>>();
|
||||||
|
|
||||||
|
Assert.Same(provider.GetRequiredService<Services.IGroupService>(), lazyGroupService.Value);
|
||||||
|
Assert.Same(provider.GetRequiredService<Services.IDynamicGroupService>(), lazyDynamicGroupService.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Plugin.WatchedTogether.Auth;
|
||||||
|
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||||
|
using MediaBrowser.Controller.Authentication;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the whole path a real login takes: a group created on demand by typing "alice+bob",
|
||||||
|
/// then logged into again afterwards by each member in turn.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The other suites mock <see cref="IProvisioningService"/>, which is why an ordering bug inside the
|
||||||
|
/// real provisioning code reached a release. These tests wire the real services together and only
|
||||||
|
/// stub the host's user manager and crypto.
|
||||||
|
/// </remarks>
|
||||||
|
[Collection(nameof(PluginTestContext))]
|
||||||
|
public class SharedAccountEndToEndTests
|
||||||
|
{
|
||||||
|
private const string AliceHash = "$PBKDF2-SHA512$iterations=210000$A1A1A1A1$AAAAAAAABBBBBBBB";
|
||||||
|
private const string BobHash = "$PBKDF2-SHA512$iterations=210000$B2B2B2B2$CCCCCCCCDDDDDDDD";
|
||||||
|
|
||||||
|
private static readonly string AuthProviderId =
|
||||||
|
typeof(SharedAccountAuthenticationProvider).FullName!;
|
||||||
|
|
||||||
|
private sealed record Harness(
|
||||||
|
SharedAccountAuthenticationProvider Provider,
|
||||||
|
List<User> Users);
|
||||||
|
|
||||||
|
private static User MakeUser(string name, string? password = null)
|
||||||
|
=> new(name, "Prov", "ResetProv") { Password = password! };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wires the real provisioning, group, dynamic-group and authentication services over a user
|
||||||
|
/// manager that behaves like Jellyfin's: password changes dispatch to the user's assigned
|
||||||
|
/// provider, and users resolve by both name and id.
|
||||||
|
/// </summary>
|
||||||
|
private static Harness MakeHarness(List<User> users, params (string Hash, string Password)[] validPairs)
|
||||||
|
{
|
||||||
|
var crypto = new StubCryptoProvider(validPairs);
|
||||||
|
var userManager = new Mock<IUserManager>();
|
||||||
|
|
||||||
|
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>()))
|
||||||
|
.Returns((Guid id) => users.Find(u => u.Id == id)!);
|
||||||
|
|
||||||
|
userManager.Setup(m => m.GetUserByName(It.IsAny<string>()))
|
||||||
|
.Returns((string n) => users.Find(
|
||||||
|
u => string.Equals(u.Username, n, StringComparison.OrdinalIgnoreCase))!);
|
||||||
|
|
||||||
|
userManager.Setup(m => m.CreateUserAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync((string name) =>
|
||||||
|
{
|
||||||
|
var created = MakeUser(name);
|
||||||
|
users.Add(created);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>())).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
userManager.SetupChangePassword(users, (user, password) =>
|
||||||
|
{
|
||||||
|
// Jellyfin routes this to the user's assigned provider; ours refuses by design.
|
||||||
|
if (string.Equals(user.AuthenticationProviderId, AuthProviderId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new NotSupportedException(
|
||||||
|
"A Watched Together shared account has no password of its own.");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = password;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
var groupService = new GroupService(
|
||||||
|
userManager.Object,
|
||||||
|
NullLogger<GroupService>.Instance);
|
||||||
|
|
||||||
|
var provisioning = new ProvisioningService(
|
||||||
|
userManager.Object,
|
||||||
|
Mock.Of<ILibraryAccessService>(),
|
||||||
|
NullLogger<ProvisioningService>.Instance);
|
||||||
|
|
||||||
|
var dynamicGroups = new DynamicGroupService(
|
||||||
|
userManager.Object,
|
||||||
|
provisioning,
|
||||||
|
crypto,
|
||||||
|
NullLogger<DynamicGroupService>.Instance);
|
||||||
|
|
||||||
|
var provider = new SharedAccountAuthenticationProvider(
|
||||||
|
crypto,
|
||||||
|
new Lazy<IGroupService>(() => groupService),
|
||||||
|
new Lazy<IDynamicGroupService>(() => dynamicGroups),
|
||||||
|
NullLogger<SharedAccountAuthenticationProvider>.Instance);
|
||||||
|
|
||||||
|
return new Harness(provider, users);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("alice-pw")]
|
||||||
|
[InlineData("bob-pw")]
|
||||||
|
public async Task GroupCreatedOnDemand_ThenUnlockedByEitherMemberPassword(string creatingPassword)
|
||||||
|
{
|
||||||
|
using var ctx = PluginTestContext.Create();
|
||||||
|
|
||||||
|
var alice = MakeUser("alice", AliceHash);
|
||||||
|
var bob = MakeUser("bob", BobHash);
|
||||||
|
var users = new List<User> { alice, bob };
|
||||||
|
|
||||||
|
var h = MakeHarness(users, (AliceHash, "alice-pw"), (BobHash, "bob-pw"));
|
||||||
|
|
||||||
|
// Creating the group by typing both names. Whichever member types their own password, the
|
||||||
|
// resulting account must behave identically.
|
||||||
|
var created = await h.Provider.Authenticate("alice+bob", creatingPassword, null);
|
||||||
|
Assert.Equal("alice+bob", created.Username);
|
||||||
|
|
||||||
|
var shared = h.Users.Find(u => u.Username == "alice+bob");
|
||||||
|
Assert.NotNull(shared);
|
||||||
|
|
||||||
|
// The account exists now, so Jellyfin resolves it and hands it to us as resolvedUser. Both
|
||||||
|
// members must be able to unlock it, regardless of who created it.
|
||||||
|
var asAlice = await h.Provider.Authenticate("alice+bob", "alice-pw", shared);
|
||||||
|
Assert.Equal("alice+bob", asAlice.Username);
|
||||||
|
|
||||||
|
var asBob = await h.Provider.Authenticate("alice+bob", "bob-pw", shared);
|
||||||
|
Assert.Equal("alice+bob", asBob.Username);
|
||||||
|
|
||||||
|
// And an outsider's password still must not.
|
||||||
|
await Assert.ThrowsAsync<AuthenticationException>(
|
||||||
|
() => h.Provider.Authenticate("alice+bob", "not-a-member-pw", shared!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GroupCreatedOnDemand_ClaimsTheAccountAndKeepsAPassword()
|
||||||
|
{
|
||||||
|
using var ctx = PluginTestContext.Create();
|
||||||
|
|
||||||
|
var alice = MakeUser("alice", AliceHash);
|
||||||
|
var bob = MakeUser("bob", BobHash);
|
||||||
|
var users = new List<User> { alice, bob };
|
||||||
|
|
||||||
|
var h = MakeHarness(users, (AliceHash, "alice-pw"), (BobHash, "bob-pw"));
|
||||||
|
|
||||||
|
await h.Provider.Authenticate("alice+bob", "alice-pw", null);
|
||||||
|
|
||||||
|
var shared = h.Users.Find(u => u.Username == "alice+bob");
|
||||||
|
Assert.NotNull(shared);
|
||||||
|
|
||||||
|
// Claimed by us, so future logins route here, and holding a password of its own so it is
|
||||||
|
// not directly loginable if the provider is ever unassigned.
|
||||||
|
Assert.Equal(AuthProviderId, shared!.AuthenticationProviderId);
|
||||||
|
Assert.False(string.IsNullOrEmpty(shared.Password));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@ public sealed class StubCryptoProvider : ICryptoProvider
|
|||||||
_validPairs = validPairs;
|
_validPairs = validPairs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the salt of each credential this provider was asked to verify, in call order. Lets a
|
||||||
|
/// test assert which member's password was checked first.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> VerifiedSalts { get; } = new();
|
||||||
|
|
||||||
public string DefaultHashMethod => "PBKDF2-SHA512";
|
public string DefaultHashMethod => "PBKDF2-SHA512";
|
||||||
|
|
||||||
public bool Verify(PasswordHash hash, ReadOnlySpan<char> password)
|
public bool Verify(PasswordHash hash, ReadOnlySpan<char> password)
|
||||||
@@ -30,6 +36,7 @@ public sealed class StubCryptoProvider : ICryptoProvider
|
|||||||
// Identify the stored credential by its salt rather than by re-formatting the whole hash,
|
// Identify the stored credential by its salt rather than by re-formatting the whole hash,
|
||||||
// which need not round-trip through Parse/ToString byte for byte.
|
// which need not round-trip through Parse/ToString byte for byte.
|
||||||
var salt = Convert.ToHexString(hash.Salt);
|
var salt = Convert.ToHexString(hash.Salt);
|
||||||
|
VerifiedSalts.Add(salt);
|
||||||
|
|
||||||
foreach (var (validHash, validPassword) in _validPairs)
|
foreach (var (validHash, validPassword) in _validPairs)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,21 +28,28 @@ namespace Jellyfin.Plugin.WatchedTogether.Auth;
|
|||||||
public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser
|
public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser
|
||||||
{
|
{
|
||||||
private readonly ICryptoProvider _cryptoProvider;
|
private readonly ICryptoProvider _cryptoProvider;
|
||||||
private readonly Services.IGroupService _groupService;
|
private readonly Lazy<Services.IGroupService> _groupService;
|
||||||
private readonly Services.IDynamicGroupService _dynamicGroupService;
|
private readonly Lazy<Services.IDynamicGroupService> _dynamicGroupService;
|
||||||
private readonly ILogger<SharedAccountAuthenticationProvider> _logger;
|
private readonly ILogger<SharedAccountAuthenticationProvider> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="SharedAccountAuthenticationProvider"/> class.
|
/// Initializes a new instance of the <see cref="SharedAccountAuthenticationProvider"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cryptoProvider">The crypto provider used to verify stored password hashes.</param>
|
/// <param name="cryptoProvider">The crypto provider used to verify stored password hashes.</param>
|
||||||
/// <param name="groupService">The group service.</param>
|
/// <param name="groupService">A deferred handle to the group service.</param>
|
||||||
/// <param name="dynamicGroupService">The on-demand group creation service.</param>
|
/// <param name="dynamicGroupService">A deferred handle to the on-demand group creation service.</param>
|
||||||
/// <param name="logger">The logger.</param>
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// The group services are taken as <see cref="Lazy{T}"/> to break a container-level cycle.
|
||||||
|
/// Jellyfin's <c>UserManager</c> constructor-injects every <see cref="IAuthenticationProvider"/>,
|
||||||
|
/// so resolving those services eagerly here would require <c>IUserManager</c> while it is still
|
||||||
|
/// being built and the host would refuse to start. Deferring the lookup to the first
|
||||||
|
/// authentication is safe: nobody can log in until the host is fully up.
|
||||||
|
/// </remarks>
|
||||||
public SharedAccountAuthenticationProvider(
|
public SharedAccountAuthenticationProvider(
|
||||||
ICryptoProvider cryptoProvider,
|
ICryptoProvider cryptoProvider,
|
||||||
Services.IGroupService groupService,
|
Lazy<Services.IGroupService> groupService,
|
||||||
Services.IDynamicGroupService dynamicGroupService,
|
Lazy<Services.IDynamicGroupService> dynamicGroupService,
|
||||||
ILogger<SharedAccountAuthenticationProvider> logger)
|
ILogger<SharedAccountAuthenticationProvider> logger)
|
||||||
{
|
{
|
||||||
_cryptoProvider = cryptoProvider;
|
_cryptoProvider = cryptoProvider;
|
||||||
@@ -73,7 +80,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
|
|||||||
// a real user with that exact name always resolves first and never reaches this branch.
|
// a real user with that exact name always resolves first and never reaches this branch.
|
||||||
if (resolvedUser is null)
|
if (resolvedUser is null)
|
||||||
{
|
{
|
||||||
var created = await _dynamicGroupService
|
var created = await _dynamicGroupService.Value
|
||||||
.TryCreateFromLoginAsync(username, password)
|
.TryCreateFromLoginAsync(username, password)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -85,7 +92,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
|
|||||||
return new ProviderAuthenticationResult { Username = created.SharedUsername };
|
return new ProviderAuthenticationResult { Username = created.SharedUsername };
|
||||||
}
|
}
|
||||||
|
|
||||||
var group = _groupService.GetGroupForSharedUser(resolvedUser.Id);
|
var group = _groupService.Value.GetGroupForSharedUser(resolvedUser.Id);
|
||||||
if (group is null)
|
if (group is null)
|
||||||
{
|
{
|
||||||
// Either not one of ours, or the group is disabled. Either way this account has no
|
// Either not one of ours, or the group is disabled. Either way this account has no
|
||||||
@@ -96,7 +103,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
|
|||||||
throw new AuthenticationException("Invalid username or password.");
|
throw new AuthenticationException("Invalid username or password.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var members = _groupService.GetEligibleMembers(group);
|
var members = _groupService.Value.GetEligibleMembers(group);
|
||||||
if (members.Count == 0)
|
if (members.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
@@ -129,12 +136,15 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
|
|||||||
throw new AuthenticationException("Invalid username or password.");
|
throw new AuthenticationException("Invalid username or password.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !JELLYFIN_12
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A shared account always has a password in the sense that matters to Jellyfin: some member
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
public bool HasPassword(User user) => true;
|
public bool HasPassword(User user) => true;
|
||||||
|
#endif
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <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.Linq;
|
||||||
using System.Net.Mime;
|
using System.Net.Mime;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.WatchedTogether.Compat;
|
||||||
using Jellyfin.Plugin.WatchedTogether.Models;
|
using Jellyfin.Plugin.WatchedTogether.Models;
|
||||||
using Jellyfin.Plugin.WatchedTogether.Services;
|
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||||
using MediaBrowser.Common.Api;
|
using MediaBrowser.Common.Api;
|
||||||
@@ -86,7 +87,7 @@ public class WatchedTogetherController : ControllerBase
|
|||||||
.Select(g => g.SharedUserId)
|
.Select(g => g.SharedUserId)
|
||||||
.ToHashSet() ?? [];
|
.ToHashSet() ?? [];
|
||||||
|
|
||||||
var users = _userManager.Users
|
var users = _userManager.GetAllUsers()
|
||||||
.Where(u => !sharedIds.Contains(u.Id))
|
.Where(u => !sharedIds.Contains(u.Id))
|
||||||
.Select(u => new MemberDto { UserId = u.Id, Username = u.Username })
|
.Select(u => new MemberDto { UserId = u.Id, Username = u.Username })
|
||||||
.OrderBy(u => u.Username, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(u => u.Username, StringComparer.OrdinalIgnoreCase)
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<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>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFrameworks>net9.0;net10.0</TargetFrameworks>
|
||||||
<RootNamespace>Jellyfin.Plugin.WatchedTogether</RootNamespace>
|
<RootNamespace>Jellyfin.Plugin.WatchedTogether</RootNamespace>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
@@ -10,7 +16,11 @@
|
|||||||
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||||
</PropertyGroup>
|
</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">
|
<PackageReference Include="Jellyfin.Controller" Version="10.11.5">
|
||||||
<ExcludeAssets>runtime</ExcludeAssets>
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
@@ -19,6 +29,15 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
</ItemGroup>
|
</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>
|
<ItemGroup>
|
||||||
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
|
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
|
||||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
|
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using Jellyfin.Plugin.WatchedTogether.Auth;
|
using Jellyfin.Plugin.WatchedTogether.Auth;
|
||||||
using Jellyfin.Plugin.WatchedTogether.Services;
|
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
@@ -20,6 +21,14 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
|||||||
serviceCollection.AddSingleton<IProvisioningService, ProvisioningService>();
|
serviceCollection.AddSingleton<IProvisioningService, ProvisioningService>();
|
||||||
serviceCollection.AddSingleton<IDynamicGroupService, DynamicGroupService>();
|
serviceCollection.AddSingleton<IDynamicGroupService, DynamicGroupService>();
|
||||||
|
|
||||||
|
// The auth provider takes these lazily so the container can build it while IUserManager is
|
||||||
|
// still under construction; see SharedAccountAuthenticationProvider's constructor remarks.
|
||||||
|
// Microsoft's container has no built-in Lazy<T> support, so the factories are explicit.
|
||||||
|
serviceCollection.AddSingleton(
|
||||||
|
provider => new Lazy<IGroupService>(provider.GetRequiredService<IGroupService>));
|
||||||
|
serviceCollection.AddSingleton(
|
||||||
|
provider => new Lazy<IDynamicGroupService>(provider.GetRequiredService<IDynamicGroupService>));
|
||||||
|
|
||||||
// Discovered by Jellyfin and matched to shared accounts via User.AuthenticationProviderId.
|
// Discovered by Jellyfin and matched to shared accounts via User.AuthenticationProviderId.
|
||||||
serviceCollection.AddSingleton<IAuthenticationProvider, SharedAccountAuthenticationProvider>();
|
serviceCollection.AddSingleton<IAuthenticationProvider, SharedAccountAuthenticationProvider>();
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,12 @@ public class DynamicGroupService : IDynamicGroupService
|
|||||||
|
|
||||||
// The password must belong to one of the named members. Without this any visitor could
|
// The password must belong to one of the named members. Without this any visitor could
|
||||||
// conjure a shared account out of two usernames they happened to know.
|
// conjure a shared account out of two usernames they happened to know.
|
||||||
if (!members.Any(m => VerifyPassword(m, password)))
|
//
|
||||||
|
// Members are tried in the order they were typed and the loop stops at the first match, so
|
||||||
|
// whoever types their own name first has their password checked first. Verification is a
|
||||||
|
// deliberately slow hash comparison, so the ordering is worth having.
|
||||||
|
var matched = members.FirstOrDefault(m => VerifyPassword(m, password));
|
||||||
|
if (matched is null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Dynamic group login for {Username} rejected: no named member's password matched",
|
"Dynamic group login for {Username} rejected: no named member's password matched",
|
||||||
@@ -124,11 +129,45 @@ public class DynamicGroupService : IDynamicGroupService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var memberIds = members.Select(m => m.Id).ToList();
|
||||||
|
|
||||||
|
// The same people in a different order are the same group: someone typing "john+jane" must
|
||||||
|
// land on the existing "jane+john" account rather than creating a second one. Jellyfin only
|
||||||
|
// reaches this code when no account matches the typed name, so without this check every
|
||||||
|
// ordering would spawn its own account.
|
||||||
|
var existing = FindGroupWithSameMembers(config, memberIds);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
if (existing.IsDisabled)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Login for {Username} rejected: the matching group is disabled",
|
||||||
|
enteredUsername);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingUser = _userManager.GetUserById(existing.SharedUserId);
|
||||||
|
if (existingUser is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Group for {Username} references a shared account that no longer exists",
|
||||||
|
enteredUsername);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Login as {Entered} resolved to the existing shared account {Username}",
|
||||||
|
enteredUsername,
|
||||||
|
existingUser.Username);
|
||||||
|
|
||||||
|
return new DynamicGroupResult(existing, existingUser.Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passing no name lets provisioning generate the canonical alphabetically-sorted one, so
|
||||||
|
// the account is named the same whichever order the members were typed in.
|
||||||
// The account is limited to the libraries all named members share, so creating one at the
|
// The account is limited to the libraries all named members share, so creating one at the
|
||||||
// login screen cannot grant anybody access they did not already have.
|
// login screen cannot grant anybody access they did not already have.
|
||||||
var group = await _provisioningService.CreateGroupAsync(
|
var group = await _provisioningService.CreateGroupAsync(memberIds, null).ConfigureAwait(false);
|
||||||
members.Select(m => m.Id).ToList(),
|
|
||||||
enteredUsername).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var sharedUser = _userManager.GetUserById(group.SharedUserId);
|
var sharedUser = _userManager.GetUserById(group.SharedUserId);
|
||||||
if (sharedUser is null)
|
if (sharedUser is null)
|
||||||
@@ -144,6 +183,22 @@ public class DynamicGroupService : IDynamicGroupService
|
|||||||
return new DynamicGroupResult(group, sharedUser.Username);
|
return new DynamicGroupResult(group, sharedUser.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a configured group whose members are exactly the given set, ignoring order.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">The plugin configuration to search.</param>
|
||||||
|
/// <param name="memberIds">The member identifiers to match.</param>
|
||||||
|
/// <returns>The matching group, or <c>null</c> if no group has that membership.</returns>
|
||||||
|
private static SharedGroup? FindGroupWithSameMembers(
|
||||||
|
PluginConfiguration config,
|
||||||
|
IReadOnlyList<Guid> memberIds)
|
||||||
|
{
|
||||||
|
var wanted = memberIds.ToHashSet();
|
||||||
|
|
||||||
|
return config.Groups.FirstOrDefault(g =>
|
||||||
|
g.MemberUserIds.Count == wanted.Count && wanted.SetEquals(g.MemberUserIds));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies a submitted password against a member's live stored hash.
|
/// Verifies a submitted password against a member's live stored hash.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using System.Security.Cryptography;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data;
|
using Jellyfin.Data;
|
||||||
using Jellyfin.Database.Implementations.Enums;
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using Jellyfin.Plugin.WatchedTogether.Compat;
|
||||||
using Jellyfin.Plugin.WatchedTogether.Configuration;
|
using Jellyfin.Plugin.WatchedTogether.Configuration;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -88,21 +89,34 @@ public class ProvisioningService : IProvisioningService
|
|||||||
members.Add(member);
|
members.Add(member);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store members in the same alphabetical order the generated name uses, so that the stored
|
||||||
|
// order is canonical however the members were supplied. Password checks then always run in
|
||||||
|
// a predictable order too.
|
||||||
|
members = members.OrderBy(m => m.Username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
distinctIds = members.Select(m => m.Id).ToList();
|
||||||
|
|
||||||
var accountName = string.IsNullOrWhiteSpace(name)
|
var accountName = string.IsNullOrWhiteSpace(name)
|
||||||
? BuildDefaultName(members.Select(m => m.Username), config.NameSeparator)
|
? BuildDefaultName(members.Select(m => m.Username), config.NameSeparator)
|
||||||
: name.Trim();
|
: name.Trim();
|
||||||
|
|
||||||
var sharedUser = await _userManager.CreateUserAsync(accountName).ConfigureAwait(false);
|
var sharedUser = await _userManager.CreateUserAsync(accountName).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// The shared account never authenticates against its own password - our provider checks
|
||||||
|
// member hashes instead. Setting a random one avoids leaving a passwordless account behind
|
||||||
|
// if the provider is ever unassigned.
|
||||||
|
//
|
||||||
|
// This must happen before the account is claimed below. IUserManager.ChangePassword
|
||||||
|
// dispatches to the provider the user is currently assigned to, and ours refuses the call
|
||||||
|
// by design, so claiming first would make provisioning throw NotSupportedException. A
|
||||||
|
// freshly created user is still on Jellyfin's default provider, which stores the hash.
|
||||||
|
// The compat wrapper also keeps sharedUser in step with what was stored, which matters on
|
||||||
|
// Jellyfin 12 where UpdateUserAsync below would otherwise overwrite the hash with null.
|
||||||
|
await _userManager.ChangePasswordAsync(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
|
||||||
|
|
||||||
// Route this account's logins through our provider. Jellyfin matches providers by
|
// 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
|
// GetType().FullName, the same key the SSO plugin uses, and the assignment only sticks
|
||||||
// once the user is updated.
|
// once the user is updated.
|
||||||
sharedUser.AuthenticationProviderId = AuthProviderId;
|
sharedUser.AuthenticationProviderId = AuthProviderId;
|
||||||
|
|
||||||
// The shared account never authenticates against its own password - our provider checks
|
|
||||||
// member hashes instead. Setting a random one avoids leaving a passwordless account behind
|
|
||||||
// if the provider is ever unassigned.
|
|
||||||
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
|
|
||||||
await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
|
await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
|
||||||
|
|
||||||
await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false);
|
await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false);
|
||||||
@@ -165,6 +179,11 @@ public class ProvisioningService : IProvisioningService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the stored order canonical, matching how groups are created.
|
||||||
|
distinctIds = distinctIds
|
||||||
|
.OrderBy(id => _userManager.GetUserById(id)?.Username, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
group.MemberUserIds = distinctIds;
|
group.MemberUserIds = distinctIds;
|
||||||
group.SyncUnwatched = syncUnwatched;
|
group.SyncUnwatched = syncUnwatched;
|
||||||
group.SyncPlayCount = syncPlayCount;
|
group.SyncPlayCount = syncPlayCount;
|
||||||
@@ -211,15 +230,20 @@ public class ProvisioningService : IProvisioningService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Joins member names into a display name, falling back to a generic name if the result would
|
/// Joins member names into a display name, falling back to a generic name if the result would
|
||||||
/// exceed the username column limit. Membership is tracked by GUID, so the name is cosmetic.
|
/// exceed the username column limit.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Names are sorted alphabetically so that a given set of members always produces the same
|
||||||
|
/// account name. Without this, "jane+john" and "john+jane" would be two different names for the
|
||||||
|
/// same group and would end up as two separate accounts.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="usernames">The member usernames.</param>
|
/// <param name="usernames">The member usernames.</param>
|
||||||
/// <param name="separator">The configured separator.</param>
|
/// <param name="separator">The configured separator.</param>
|
||||||
/// <returns>A name that fits within the username length limit.</returns>
|
/// <returns>A name that fits within the username length limit.</returns>
|
||||||
private static string BuildDefaultName(IEnumerable<string> usernames, string separator)
|
private static string BuildDefaultName(IEnumerable<string> usernames, string separator)
|
||||||
{
|
{
|
||||||
var sep = string.IsNullOrEmpty(separator) ? "+" : separator;
|
var sep = string.IsNullOrEmpty(separator) ? "+" : separator;
|
||||||
var joined = string.Join(sep, usernames);
|
var joined = string.Join(sep, usernames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (joined.Length <= MaxUsernameLength)
|
if (joined.Length <= MaxUsernameLength)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.WatchedTogether.Compat;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -98,7 +99,7 @@ public sealed class UserLifecycleService : IHostedService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var liveIds = _userManager.UsersIds.ToHashSet();
|
var liveIds = _userManager.GetAllUserIds().ToHashSet();
|
||||||
|
|
||||||
var referenced = config.Groups
|
var referenced = config.Groups
|
||||||
.SelectMany(g => g.MemberUserIds.Append(g.SharedUserId))
|
.SelectMany(g => g.MemberUserIds.Append(g.SharedUserId))
|
||||||
|
|||||||
@@ -81,13 +81,23 @@ On an unrecognised name, it:
|
|||||||
|
|
||||||
1. Splits the name on the separator (`+` by default) — `alice+bob+carol` → three parts.
|
1. Splits the name on the separator (`+` by default) — `alice+bob+carol` → three parts.
|
||||||
2. Requires **every part** to be an existing, enabled user that is not itself a shared account.
|
2. Requires **every part** to be an existing, enabled user that is not itself a shared account.
|
||||||
3. Requires the submitted password to match **one of those members'** stored hashes.
|
3. Requires the submitted password to match **one of those members'** stored hashes. Members are
|
||||||
4. Only then creates the shared account, and returns its name so Jellyfin completes the login.
|
checked in the order you typed them and the check stops at the first match, so putting your own
|
||||||
|
name first is marginally quicker.
|
||||||
|
4. Looks for an existing group with exactly those members. If one exists, you are logged into it.
|
||||||
|
5. Otherwise creates the shared account, and returns its name so Jellyfin completes the login.
|
||||||
|
|
||||||
Step 3 is what stops this being an open door: knowing two usernames is not enough to bring an
|
Step 3 is what stops this being an open door: knowing two usernames is not enough to bring an
|
||||||
account into being. If any check fails, the plugin declines and the login fails exactly as an
|
account into being. If any check fails, the plugin declines and the login fails exactly as an
|
||||||
ordinary typo would.
|
ordinary typo would.
|
||||||
|
|
||||||
|
#### Order does not matter
|
||||||
|
|
||||||
|
`john+jane` and `jane+john` are the same group. Member names are sorted alphabetically to build the
|
||||||
|
account name, and the lookup in step 4 compares members as a set, so both spellings resolve to one
|
||||||
|
account rather than creating a second one for the same two people. The account itself is named with
|
||||||
|
the sorted spelling — `jane+john` — whichever order you happened to type.
|
||||||
|
|
||||||
#### The name collision, and why it is harmless
|
#### The name collision, and why it is harmless
|
||||||
|
|
||||||
`+` is a legal Jellyfin username character:
|
`+` is a legal Jellyfin username character:
|
||||||
@@ -154,8 +164,10 @@ Then install **Watched Together** from the catalogue and restart Jellyfin.
|
|||||||
|
|
||||||
### Manual
|
### Manual
|
||||||
|
|
||||||
Download the release `.zip`, extract it into a `WatchedTogether` folder inside your Jellyfin
|
Each release ships two `.zip` files: one for Jellyfin **10.11** and one for Jellyfin **12** (the
|
||||||
`plugins` directory, and restart the server.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -165,7 +177,7 @@ Download the release `.zip`, extract it into a `WatchedTogether` folder inside y
|
|||||||
|
|
||||||
On the shared device, at the Jellyfin login screen:
|
On the shared device, at the Jellyfin login screen:
|
||||||
|
|
||||||
- **Username:** `alice+bob` (the members' usernames, joined with `+`)
|
- **Username:** `alice+bob` (the members' usernames, joined with `+`, in any order)
|
||||||
- **Password:** your own
|
- **Password:** your own
|
||||||
|
|
||||||
That is the whole setup. The account is created on first use and reused from then on. Add a third
|
That is the whole setup. The account is created on first use and reused from then on. Add a third
|
||||||
@@ -177,7 +189,8 @@ If you would rather provision groups explicitly — or you have turned auto-crea
|
|||||||
|
|
||||||
1. Go to **Dashboard → Plugins → Watched Together**.
|
1. Go to **Dashboard → Plugins → Watched Together**.
|
||||||
2. Under **Create a group**, select **two or more** members.
|
2. Under **Create a group**, select **two or more** members.
|
||||||
3. Optionally give the account a name. Left blank, the member names are joined with `+`.
|
3. Optionally give the account a name. Left blank, the member names are sorted alphabetically and
|
||||||
|
joined with `+`.
|
||||||
4. Click **Create group**.
|
4. Click **Create group**.
|
||||||
|
|
||||||
Either way, a new user appears in your user list and can be renamed like any other.
|
Either way, a new user appears in your user list and can be renamed like any other.
|
||||||
@@ -228,18 +241,26 @@ How this plugin bounds what a shared account can reach.
|
|||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
| | |
|
| Jellyfin | Framework | Built against | Package version |
|
||||||
| --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Target ABI | Jellyfin **10.11.x** |
|
| **10.11.x** | .NET 9 | `Jellyfin.Controller` 10.11.5 | `x.y.z.11` |
|
||||||
| Framework | .NET 9 |
|
| **12.x** | .NET 10 | `Jellyfin.Controller` 12.0.0 | `x.y.z.12` |
|
||||||
|
|
||||||
Verified against the 10.11.5 SDK: `IAuthenticationProvider` + `IRequiresResolvedUser`,
|
One source tree, one build per server generation. Jellyfin 12 turned `IUserManager.Users` and
|
||||||
`ICryptoProvider.Verify`, `IUserDataManager.UserDataSaved`, and a 255-character username limit.
|
`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
|
Auto-creation depends on `UserManager.AuthenticateUser` offering unmatched usernames to every
|
||||||
enabled provider and re-querying the database afterwards ("the authentication provider might have
|
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
|
created it"). That behaviour is present in both 10.11.5 and 12.0; if a future release changes it,
|
||||||
stops working and dashboard provisioning continues to.
|
auto-creation stops working and dashboard provisioning continues to.
|
||||||
|
|
||||||
Jellyfin's plugin API changes across minor versions, `IServerEntryPoint` gave way 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
|
`IHostedService` around 10.9, and entity types moved namespaces in 10.11. Expect to rebuild against
|
||||||
@@ -249,26 +270,35 @@ the matching SDK when upgrading the server.
|
|||||||
|
|
||||||
## Building
|
## 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
|
```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
|
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
|
```bash
|
||||||
dotnet build Jellyfin.Plugin.WatchedTogether.sln -c Release
|
dotnet build Jellyfin.Plugin.WatchedTogether.sln -c Release
|
||||||
dotnet test 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
|
```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
|
### CI
|
||||||
|
|
||||||
Gitea Actions workflows live in [.gitea/workflows/](.gitea/workflows/):
|
Gitea Actions workflows live in [.gitea/workflows/](.gitea/workflows/):
|
||||||
@@ -276,8 +306,10 @@ Gitea Actions workflows live in [.gitea/workflows/](.gitea/workflows/):
|
|||||||
| Workflow | Trigger | Does |
|
| Workflow | Trigger | Does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `test.yaml` | push / PR | Debug build and test run, uploads `.trx` results |
|
| `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 |
|
| `build.yaml` | push / PR to `master` | Release build, tests, and a date-versioned plugin zip per Jellyfin generation |
|
||||||
| `release.yaml` | tag `v*.*.*` | Builds, creates a Gitea release, and updates `manifest.json` |
|
| `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):
|
All three run in the builder image defined by [Dockerfile.builder](Dockerfile.builder):
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -1,7 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: "Watched Together"
|
name: "Watched Together"
|
||||||
guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b"
|
guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b"
|
||||||
version: "0.0.1.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"
|
targetAbi: "10.11.0.0"
|
||||||
framework: "net9.0"
|
framework: "net9.0"
|
||||||
overview: "One shared login for several people; watched state flows back to each member's own account"
|
overview: "One shared login for several people; watched state flows back to each member's own account"
|
||||||
@@ -26,6 +28,6 @@ dotnet_framework: "net9.0"
|
|||||||
# Point at the plugin project rather than the solution so the test project is not packaged.
|
# Point at the plugin project rather than the solution so the test project is not packaged.
|
||||||
project: "Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj"
|
project: "Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj"
|
||||||
changelog: >
|
changelog: >
|
||||||
Initial release: shared accounts created on demand at login, multi-password
|
Adds support for Jellyfin 12 alongside 10.11. Each release now ships two packages:
|
||||||
authentication, one-way played-state sync to members, and library access
|
one built against 10.11 on .NET 9 and one against 12 on .NET 10, and the plugin
|
||||||
computed as the intersection of the members'.
|
repository serves whichever matches your server.
|
||||||
|
|||||||
@@ -100,6 +100,9 @@
|
|||||||
<Rule Id="CA1308" Action="None" />
|
<Rule Id="CA1308" Action="None" />
|
||||||
<!-- disable warning CA1848: Use the LoggerMessage delegates -->
|
<!-- disable warning CA1848: Use the LoggerMessage delegates -->
|
||||||
<Rule Id="CA1848" Action="None" />
|
<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 -->
|
<!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments -->
|
||||||
<Rule Id="CA2101" Action="None" />
|
<Rule Id="CA2101" Action="None" />
|
||||||
<!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
|
<!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
|
||||||
|
|||||||
+34
-1
@@ -6,6 +6,39 @@
|
|||||||
"overview": "One shared login for several people; watched state flows back to each member's own account",
|
"overview": "One shared login for several people; watched state flows back to each member's own account",
|
||||||
"owner": "dtourolle",
|
"owner": "dtourolle",
|
||||||
"category": "General",
|
"category": "General",
|
||||||
"versions": []
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.0.4.0",
|
||||||
|
"changelog": "Release 0.0.4.0",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.4.0/watched-together_0.0.4.0.zip",
|
||||||
|
"checksum": "f537c5305ac6fcb19024471968759bb5",
|
||||||
|
"timestamp": "2026-08-09T08:59:11Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.0.3.0",
|
||||||
|
"changelog": "Release 0.0.3.0",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.3.0/watched-together_0.0.3.0.zip",
|
||||||
|
"checksum": "56aa6c2e8f12d7404889de8bc253eae3",
|
||||||
|
"timestamp": "2026-08-09T08:49:27Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.0.2",
|
||||||
|
"changelog": "Release 0.0.2",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.2/watched-together_0.0.2.0.zip",
|
||||||
|
"checksum": "0fedb68a0910414518d7dc45f05fd78f",
|
||||||
|
"timestamp": "2026-07-31T07:45:08Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"changelog": "Release 0.0.1",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.1/watched-together_0.0.1.0.zip",
|
||||||
|
"checksum": "f703f027db22c0bc348b1cb0cee6bb08",
|
||||||
|
"timestamp": "2026-07-29T22:17:28Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
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