aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Shadowghost@users.noreply.github.com>2026-09-15 11:16:05 -0400
committerCody Robibero <cody@robibe.ro>2026-09-15 11:16:05 -0400
commit93fc178db56835b08943825ea6873025c4cfd44b (patch)
treee0caea115f41022f2d643097490cdf078a660a70
parentfcd010d30812664e5b3df539fc30b10d8b23a96a (diff)
Backport pull request #17938 from jellyfin/release-12.z
Fix SyncPlay authentication error handling and limit group member wait time Original-merge: 5fcb65bb46b1ef528344e90e4cd0f7f974807e9e Merged-by: crobibero <cody@robibe.ro> Backported-by: Cody Robibero <cody@robibe.ro>
-rw-r--r--Emby.Server.Implementations/SyncPlay/Group.cs125
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs69
-rw-r--r--Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs8
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs58
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs181
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs36
6 files changed, 457 insertions, 20 deletions
diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs
index 923bfc67aa..6fbe46ffd6 100644
--- a/Emby.Server.Implementations/SyncPlay/Group.cs
+++ b/Emby.Server.Implementations/SyncPlay/Group.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Controller.SyncPlay;
using MediaBrowser.Controller.SyncPlay.GroupStates;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
using MediaBrowser.Controller.SyncPlay.Queue;
using MediaBrowser.Controller.SyncPlay.Requests;
using MediaBrowser.Model.SyncPlay;
@@ -27,6 +28,11 @@ namespace Emby.Server.Implementations.SyncPlay
public class Group : IGroupStateContext
{
/// <summary>
+ /// The default value of <see cref="GroupWaitTimeout"/>, in milliseconds.
+ /// </summary>
+ internal const long DefaultGroupWaitTimeout = 30000;
+
+ /// <summary>
/// The logger.
/// </summary>
private readonly ILogger<Group> _logger;
@@ -54,8 +60,12 @@ namespace Emby.Server.Implementations.SyncPlay
/// <summary>
/// The participants, or members of the group.
/// </summary>
- private readonly Dictionary<string, GroupMember> _participants =
- new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary<string, GroupMember> _participants = new(StringComparer.OrdinalIgnoreCase);
+
+ /// <summary>
+ /// The sessions of the participants, which only carry identifiers.
+ /// </summary>
+ private readonly Dictionary<string, SessionInfo> _participantSessions = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The internal group state.
@@ -115,6 +125,19 @@ namespace Emby.Server.Implementations.SyncPlay
public long MaxPlaybackOffset { get; } = 500;
/// <summary>
+ /// Gets the maximum time, in milliseconds, the group waits for its members to report ready.
+ /// </summary>
+ /// <value>The group-wait timeout.</value>
+ internal long GroupWaitTimeout { get; init; } = DefaultGroupWaitTimeout;
+
+ /// <summary>
+ /// Gets the <see cref="Environment.TickCount64"/> value at which the group gives up waiting
+ /// for its members, or <c>null</c> when it is not waiting for anyone.
+ /// </summary>
+ /// <value>The group-wait deadline.</value>
+ internal long? GroupWaitDeadline { get; private set; }
+
+ /// <summary>
/// Gets the group identifier.
/// </summary>
/// <value>The group identifier.</value>
@@ -163,6 +186,8 @@ namespace Emby.Server.Implementations.SyncPlay
Ping = DefaultPing,
IsBuffering = false
});
+
+ _participantSessions[session.Id] = session;
}
/// <summary>
@@ -172,6 +197,8 @@ namespace Emby.Server.Implementations.SyncPlay
private void RemoveSession(SessionInfo session)
{
_participants.Remove(session.Id);
+ _participantSessions.Remove(session.Id);
+ UpdateGroupWaitDeadline(false);
}
/// <summary>
@@ -389,13 +416,20 @@ namespace Emby.Server.Implementations.SyncPlay
{
value.IgnoreGroupWait = ignoreGroupWait;
}
+
+ UpdateGroupWaitDeadline(false);
}
/// <inheritdoc />
public void SetState(IGroupState state)
{
_logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type);
- this._state = state;
+ _state = state;
+
+ if (state.Type != GroupStateType.Waiting)
+ {
+ GroupWaitDeadline = null;
+ }
}
/// <inheritdoc />
@@ -475,6 +509,8 @@ namespace Emby.Server.Implementations.SyncPlay
{
value.IsBuffering = isBuffering;
}
+
+ UpdateGroupWaitDeadline(false);
}
/// <inheritdoc />
@@ -484,6 +520,9 @@ namespace Emby.Server.Implementations.SyncPlay
{
session.IsBuffering = isBuffering;
}
+
+ // Resetting the status of every session starts a new waiting period.
+ UpdateGroupWaitDeadline(isBuffering);
}
/// <inheritdoc />
@@ -690,5 +729,85 @@ namespace Emby.Server.Implementations.SyncPlay
PlayQueue.ShuffleMode,
PlayQueue.RepeatMode);
}
+
+ /// <summary>
+ /// Stops waiting for the members that have not reported ready and lets the rest of the
+ /// group carry on. Does nothing until <see cref="GroupWaitDeadline"/> has passed.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ internal void HandleGroupWaitTimeout(CancellationToken cancellationToken)
+ {
+ var deadline = GroupWaitDeadline;
+ if (deadline is null || deadline > Environment.TickCount64)
+ {
+ return;
+ }
+
+ GroupWaitDeadline = null;
+
+ if (_state is not WaitingGroupState waitingState)
+ {
+ return;
+ }
+
+ var blockingSessions = _participantSessions
+ .Values
+ .Where(participant => _participants.TryGetValue(participant.Id, out var member)
+ && member.IsBuffering
+ && !member.IgnoreGroupWait)
+ .ToList();
+
+ if (blockingSessions.Count == 0)
+ {
+ return;
+ }
+
+ // The recovery below is broadcast to the whole group, so it does not matter which of
+ // the sessions that kept the group waiting is the one acting on the group's behalf.
+ var session = blockingSessions[0];
+
+ _logger.LogWarning(
+ "Group {GroupId} waited {Waited} ms for session(s) {SessionIds} to report ready, giving up.",
+ GroupId.ToString(),
+ GroupWaitTimeout + Environment.TickCount64 - deadline.Value,
+ string.Join(", ", blockingSessions.Select(participant => participant.Id)));
+
+ if (waitingState.ResumePlaying)
+ {
+ // An unpause request in the waiting state means "start now, ignoring the sessions
+ // that are not ready".
+ var unpauseRequest = new UnpauseGroupRequest();
+ waitingState.HandleRequest(unpauseRequest, this, GroupStateType.Waiting, session, cancellationToken);
+ return;
+ }
+
+ // The members have been paused for the whole waiting period, so the playback position
+ // stays where the wait started.
+ SetAllBuffering(false);
+ SetState(new PausedGroupState(_loggerFactory));
+
+ var command = NewSyncPlayCommand(SendCommandType.Pause);
+ SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ var stateUpdate = new GroupStateUpdate(GroupStateType.Paused, PlaybackRequestType.Pause);
+ var update = new SyncPlayStateUpdate(GroupId, stateUpdate);
+ SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ private void UpdateGroupWaitDeadline(bool startNewWaitingPeriod)
+ {
+ if (_state.Type != GroupStateType.Waiting || !IsBuffering())
+ {
+ GroupWaitDeadline = null;
+ return;
+ }
+
+ // A running deadline covers the waiting period as a whole, so the sessions that keep
+ // reporting buffering while they load must not push it back.
+ if (GroupWaitDeadline is null || startNewWaitingPeriod)
+ {
+ GroupWaitDeadline = Environment.TickCount64 + GroupWaitTimeout;
+ }
+ }
}
}
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
index b88ee33358..88dfb070b8 100644
--- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
+++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
@@ -19,6 +19,11 @@ namespace Emby.Server.Implementations.SyncPlay
public class SyncPlayManager : ISyncPlayManager, IDisposable
{
/// <summary>
+ /// How often, in milliseconds, the groups are checked for a spent wait deadline.
+ /// </summary>
+ private const int GroupWaitSweepInterval = 1000;
+
+ /// <summary>
/// The logger.
/// </summary>
private readonly ILogger<SyncPlayManager> _logger;
@@ -69,6 +74,11 @@ namespace Emby.Server.Implementations.SyncPlay
/// </remarks>
private readonly Lock _groupsLock = new();
+ /// <summary>
+ /// The timer that watches the groups' wait deadlines, running only while there are groups.
+ /// </summary>
+ private readonly Timer _groupWaitTimer;
+
private bool _disposed = false;
/// <summary>
@@ -90,8 +100,15 @@ namespace Emby.Server.Implementations.SyncPlay
_libraryManager = libraryManager;
_logger = loggerFactory.CreateLogger<SyncPlayManager>();
_sessionManager.SessionEnded += OnSessionEnded;
+ _groupWaitTimer = new Timer(_ => OnGroupWaitTimerTick(), null, Timeout.Infinite, Timeout.Infinite);
}
+ /// <summary>
+ /// Gets the maximum time, in milliseconds, a group waits for its members to report ready.
+ /// </summary>
+ /// <value>The group-wait timeout.</value>
+ internal long GroupWaitTimeout { get; init; } = Group.DefaultGroupWaitTimeout;
+
/// <inheritdoc />
public void Dispose()
{
@@ -122,8 +139,12 @@ namespace Emby.Server.Implementations.SyncPlay
LeaveGroup(session, leaveGroupRequest, cancellationToken);
}
- var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager);
+ var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager)
+ {
+ GroupWaitTimeout = GroupWaitTimeout
+ };
_groups[group.GroupId] = group;
+ UpdateGroupWaitTimer();
if (!_sessionToGroupMap.TryAdd(session.Id, group))
{
@@ -242,6 +263,7 @@ namespace Emby.Server.Implementations.SyncPlay
{
_logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId);
_groups.Remove(group.GroupId, out _);
+ UpdateGroupWaitTimer();
}
}
}
@@ -384,7 +406,50 @@ namespace Emby.Server.Implementations.SyncPlay
}
_sessionManager.SessionEnded -= OnSessionEnded;
- _disposed = true;
+
+ lock (_groupsLock)
+ {
+ _disposed = true;
+ _groupWaitTimer.Dispose();
+ }
+ }
+
+ private void UpdateGroupWaitTimer()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ var interval = _groups.IsEmpty ? Timeout.Infinite : GroupWaitSweepInterval;
+ _groupWaitTimer.Change(interval, interval);
+ }
+
+ private void OnGroupWaitTimerTick()
+ {
+ try
+ {
+ lock (_groupsLock)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ foreach (var (_, group) in _groups)
+ {
+ // Group lock required as Group is not thread-safe.
+ lock (group)
+ {
+ group.HandleGroupWaitTimeout(CancellationToken.None);
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while recovering SyncPlay groups from a timed out wait.");
+ }
}
private void OnSessionEnded(object sender, SessionEventArgs e)
diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
index 7efb5b1698..76874d10de 100644
--- a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
+++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
@@ -2,6 +2,7 @@ using System.Threading.Tasks;
using Jellyfin.Api.Extensions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.SyncPlay;
@@ -34,6 +35,13 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement)
{
var userId = context.User.GetUserId();
+ if (userId.IsEmpty())
+ {
+ // Unauthenticated requests and API keys carry no user, so there is nothing to
+ // check: leave the requirement unsatisfied and let the request be challenged.
+ return Task.CompletedTask;
+ }
+
var user = _userManager.GetUserById(userId);
if (user is null)
{
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
index b1221f6f71..ecd8fafe80 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
@@ -1,12 +1,17 @@
using System;
using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
using MediaBrowser.Controller.SyncPlay.Requests;
+using MediaBrowser.Model.SyncPlay;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
+using SyncPlayGroup = Emby.Server.Implementations.SyncPlay.Group;
using SyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager;
namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
@@ -55,11 +60,33 @@ public class SyncPlayManagerTests
Assert.False(harness.Manager.IsUserActive(harness.User.Id));
}
+ [Fact]
+ public async Task HandleRequest_GroupWaitsForAMemberThatNeverReportsReady_RecoversOnItsOwn()
+ {
+ var harness = new ManagerHarness(groupWaitTimeout: 200);
+ var second = harness.CreateSession("session-2");
+
+ var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ harness.Manager.JoinGroup(second, new JoinGroupRequest(info.GroupId), CancellationToken.None);
+
+ // Starting playback puts the group behind the ready barrier.
+ harness.Manager.HandleRequest(
+ harness.Session,
+ new PlayGroupRequest(new[] { Guid.NewGuid() }, 0, 0),
+ CancellationToken.None);
+ Assert.Equal(GroupStateType.Waiting, harness.Manager.GetGroup(harness.Session, info.GroupId).State);
+
+ // Neither session ever reports ready, so the group has to come out of the wait by itself.
+ Assert.Equal(
+ GroupStateType.Playing,
+ await harness.WaitForState(harness.Session, info.GroupId, GroupStateType.Playing));
+ }
+
private sealed class ManagerHarness
{
private readonly Mock<ISessionManager> _sessionManager = new();
- public ManagerHarness()
+ public ManagerHarness(long? groupWaitTimeout = null)
{
var userManager = new Mock<IUserManager>();
var libraryManager = new Mock<ILibraryManager>();
@@ -67,11 +94,26 @@ public class SyncPlayManagerTests
User = new User("tester", "auth-provider", "pwdreset-provider");
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(User);
+ var item = new Mock<BaseItem>();
+ item.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true);
+ item.Object.RunTimeTicks = TimeSpan.FromHours(2).Ticks;
+ libraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(item.Object);
+
+ _sessionManager
+ .Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>()))
+ .Returns(Task.CompletedTask);
+ _sessionManager
+ .Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>()))
+ .Returns(Task.CompletedTask);
+
Manager = new SyncPlayManager(
NullLoggerFactory.Instance,
userManager.Object,
_sessionManager.Object,
- libraryManager.Object);
+ libraryManager.Object)
+ {
+ GroupWaitTimeout = groupWaitTimeout ?? SyncPlayGroup.DefaultGroupWaitTimeout
+ };
Session = CreateSession("session-1");
}
@@ -91,5 +133,17 @@ public class SyncPlayManagerTests
UserName = User.Username
};
}
+
+ public async Task<GroupStateType> WaitForState(SessionInfo session, Guid groupId, GroupStateType expected)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(10);
+ GroupStateType state;
+ while ((state = Manager.GetGroup(session, groupId).State) != expected && DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+
+ return state;
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
index 81af12ba8c..d3cbc9b8be 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
@@ -204,9 +204,131 @@ public class WaitingGroupStateTests
Assert.InRange(group.LastActivity - before, TimeSpan.Zero, TimeSpan.FromMinutes(1));
}
+ [Fact]
+ public async Task SessionJoined_JoinerNeverReportsReady_GroupResumesWithoutIt()
+ {
+ var harness = new GroupHarness(groupWaitTimeout: 200);
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetState(new PlayingGroupState(NullLoggerFactory.Instance));
+
+ // A session joins while the group is playing: the group pauses and waits for it.
+ var joiner = harness.NewSession("joiner");
+ group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None);
+
+ Assert.Equal(GroupStateType.Waiting, group.GetInfo().State);
+
+ // The joiner's player aborts and never reports ready. Without a bounded wait the whole
+ // group stays paused forever.
+ await harness.WaitForState(GroupStateType.Playing);
+
+ // Late buffer reports from the session that missed the deadline must not drag the group
+ // back into waiting.
+ group.HandleRequest(
+ joiner,
+ new BufferGroupRequest(DateTime.UtcNow, 0, false, harness.PlaylistItemId),
+ CancellationToken.None);
+
+ Assert.Equal(GroupStateType.Playing, group.GetInfo().State);
+ }
+
+ [Fact]
+ public async Task SessionJoined_GroupWasPaused_TimeoutLeavesTheGroupPaused()
+ {
+ var harness = new GroupHarness(groupWaitTimeout: 200);
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+
+ // The group has been sitting paused for a while before anyone joins.
+ group.LastActivity = DateTime.UtcNow.AddMinutes(-2);
+ group.SetState(new PausedGroupState(NullLoggerFactory.Instance));
+
+ var joiner = harness.NewSession("joiner");
+ group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None);
+
+ Assert.Equal(GroupStateType.Waiting, group.GetInfo().State);
+
+ // A group that was paused must not start playing because a member failed to report ready.
+ await harness.WaitForState(GroupStateType.Paused);
+
+ // Giving up on the joiner must not move the playback position of an already paused group.
+ Assert.Equal(TimeSpan.FromMinutes(5).Ticks, group.PositionTicks);
+
+ // Every member has to be told the group is no longer waiting.
+ var recipients = harness.StateUpdates
+ .Where(update => update.Update.State == GroupStateType.Paused)
+ .Select(update => update.SessionId)
+ .ToList();
+ Assert.Contains(harness.First.Id, recipients);
+ Assert.Contains(harness.Second.Id, recipients);
+ Assert.Contains(joiner.Id, recipients);
+ }
+
+ [Fact]
+ public async Task Ready_ReportedBeforeTheDeadline_GroupDoesNotGiveUpOnAnyone()
+ {
+ var harness = new GroupHarness(groupWaitTimeout: 200);
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetState(new PlayingGroupState(NullLoggerFactory.Instance));
+
+ var joiner = harness.NewSession("joiner");
+ group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None);
+ Assert.Equal(GroupStateType.Waiting, group.GetInfo().State);
+
+ group.HandleRequest(
+ joiner,
+ new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId),
+ CancellationToken.None);
+
+ // Everyone reported ready, so no deadline is left to trip and force a spurious unpause.
+ Assert.Equal(GroupStateType.Playing, group.GetInfo().State);
+ Assert.Null(group.GroupWaitDeadline);
+
+ var until = DateTime.UtcNow.AddMilliseconds(3 * 200);
+ while (DateTime.UtcNow < until)
+ {
+ harness.PumpGroupWaitTimeout();
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+
+ Assert.Equal(GroupStateType.Playing, group.GetInfo().State);
+ }
+
+ [Fact]
+ public async Task SetPlaylistItem_AfterATimeout_GroupWaitsForEveryoneAgain()
+ {
+ var harness = new GroupHarness(groupWaitTimeout: 200);
+ var group = harness.Group;
+
+ group.LastActivity = DateTime.UtcNow;
+ group.SetState(new PlayingGroupState(NullLoggerFactory.Instance));
+
+ var joiner = harness.NewSession("joiner");
+ group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None);
+ await harness.WaitForState(GroupStateType.Playing);
+
+ // Giving up on a session lasts only until the group changes what it is playing.
+ group.HandleRequest(
+ harness.First,
+ new SetPlaylistItemGroupRequest(harness.PlaylistItemId),
+ CancellationToken.None);
+
+ Assert.Equal(GroupStateType.Waiting, group.GetInfo().State);
+ Assert.NotNull(group.GroupWaitDeadline);
+ }
+
private sealed class GroupHarness
{
- public GroupHarness()
+ private readonly ISessionManager _sessionManager;
+ private readonly Guid _userId;
+
+ public GroupHarness(long? groupWaitTimeout = null)
{
var userManager = new Mock<IUserManager>();
var sessionManager = new Mock<ISessionManager>();
@@ -227,27 +349,24 @@ public class WaitingGroupStateTests
sessionManager
.Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>()))
+ .Callback((string sessionId, GroupUpdate<GroupStateUpdate> update, CancellationToken _) => StateUpdates.Add((sessionId, update.Data)))
.Returns(Task.CompletedTask);
Group = new SyncPlayGroup(
NullLoggerFactory.Instance,
userManager.Object,
sessionManager.Object,
- libraryManager.Object);
-
- First = new SessionInfo(sessionManager.Object, NullLogger.Instance)
+ libraryManager.Object)
{
- Id = "first",
- UserId = user.Id,
- UserName = "first"
- };
- Second = new SessionInfo(sessionManager.Object, NullLogger.Instance)
- {
- Id = "second",
- UserId = user.Id,
- UserName = "second"
+ GroupWaitTimeout = groupWaitTimeout ?? SyncPlayGroup.DefaultGroupWaitTimeout
};
+ _sessionManager = sessionManager.Object;
+ _userId = user.Id;
+
+ First = NewSession("first");
+ Second = NewSession("second");
+
Group.CreateGroup(First, new NewGroupRequest("group"), CancellationToken.None);
Group.SessionJoin(Second, new JoinGroupRequest(Group.GroupId), CancellationToken.None);
Group.SetPlayQueue(new List<Guid> { Guid.NewGuid() }, 0, 0);
@@ -256,6 +375,8 @@ public class WaitingGroupStateTests
public SyncPlayGroup Group { get; }
+ public List<(string SessionId, GroupStateUpdate Update)> StateUpdates { get; } = new();
+
public SessionInfo First { get; }
public SessionInfo Second { get; }
@@ -263,5 +384,39 @@ public class WaitingGroupStateTests
public Guid PlaylistItemId { get; }
public List<SendCommand> Commands { get; } = new List<SendCommand>();
+
+ // Mirrors the sweep SyncPlayManager runs on a timer.
+ public void PumpGroupWaitTimeout()
+ {
+ var group = Group;
+
+ // Group lock required as Group is not thread-safe.
+ lock (group)
+ {
+ group.HandleGroupWaitTimeout(CancellationToken.None);
+ }
+ }
+
+ public async Task WaitForState(GroupStateType expected)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(10);
+ while (Group.GetInfo().State != expected && DateTime.UtcNow < deadline)
+ {
+ PumpGroupWaitTimeout();
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+
+ Assert.Equal(expected, Group.GetInfo().State);
+ }
+
+ public SessionInfo NewSession(string id)
+ {
+ return new SessionInfo(_sessionManager, NullLogger.Instance)
+ {
+ Id = id,
+ UserId = _userId,
+ UserName = id
+ };
+ }
}
}
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs
new file mode 100644
index 0000000000..f84e28de76
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs
@@ -0,0 +1,36 @@
+using System.Net;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Jellyfin.Server.Integration.Tests.Controllers;
+
+public sealed class SyncPlayControllerTests : IClassFixture<JellyfinApplicationFactory>
+{
+ private readonly JellyfinApplicationFactory _factory;
+
+ public SyncPlayControllerTests(JellyfinApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Fact]
+ public async Task GetGroups_Unauthorized_ReturnsUnauthorized()
+ {
+ var client = _factory.CreateClient();
+
+ var response = await client.GetAsync("/SyncPlay/List", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetGroups_InvalidToken_ReturnsUnauthorized()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader("invalid-token");
+
+ var response = await client.GetAsync("/SyncPlay/List", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+}