Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f430d288ea | ||
|
|
5c8430f207 | ||
|
|
cb95a317d0 |
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.0.1.0</Version>
|
<Version>0.0.2.0</Version>
|
||||||
<AssemblyVersion>0.0.1.0</AssemblyVersion>
|
<AssemblyVersion>0.0.2.0</AssemblyVersion>
|
||||||
<FileVersion>0.0.1.0</FileVersion>
|
<FileVersion>0.0.2.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ 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();
|
||||||
@@ -165,6 +171,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 +222,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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -165,7 +175,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 +187,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.
|
||||||
|
|||||||
+5
-4
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
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.2.0"
|
||||||
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 +26,7 @@ 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
|
Member order no longer matters when resolving a group: "john+jane" and
|
||||||
authentication, one-way played-state sync to members, and library access
|
"jane+john" are the same account instead of creating a second one. Account
|
||||||
computed as the intersection of the members'.
|
names are sorted alphabetically, and a member's password is checked in the
|
||||||
|
order the names were typed.
|
||||||
|
|||||||
+10
-1
@@ -6,6 +6,15 @@
|
|||||||
"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.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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user