Backport pull request #17797 from jellyfin/release-12.z

Correct SyncPlay sessions that report playback at a stale position

Original-merge: a93180d35f

Merged-by: crobibero <cody@robibe.ro>

Backported-by: Cody Robibero <cody@robibe.ro>
This commit is contained in:
fmarcac authored and Cody Robibero committed 2026-09-15 11:13:44 -04:00
1 parent c8838e704f
commit b70e7f60ff
3 files changed
+150 -1

No files matched your search

@@ -50,6 +50,11 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
/// </summary>
private GroupStateType InitialState { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the group position moved during this wait.
/// </summary>
private bool PositionJumped { get; set; }
/// <inheritdoc />
public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
{
@@ -136,6 +141,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
ResumePlaying = true;
var setQueueStatus = context.SetPlayQueue(request.PlayingQueue, request.PlayingItemPosition, request.StartPositionTicks);
PositionJumped = setQueueStatus;
if (!setQueueStatus)
{
_logger.LogError("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString());
@@ -175,6 +181,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
ResumePlaying = true;
var result = context.SetPlayingItem(request.PlaylistItemId);
PositionJumped = result;
if (result)
{
var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem);
@@ -214,6 +221,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
{
ResumePlaying = true;
context.RestartCurrentItem();
PositionJumped = true;
var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist);
var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate);
@@ -310,6 +318,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
// Seek.
context.PositionTicks = ticks;
context.LastActivity = DateTime.UtcNow;
PositionJumped = true;
var command = context.NewSyncPlayCommand(SendCommandType.Seek);
context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
@@ -450,7 +459,13 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
{
// Handle case where session reported as ready but in reality
// it has no clue of the real position nor the playback state.
if (!request.IsPlaying && Math.Abs(delayTicks) > maxPlaybackOffsetTicks)
// A jump means the session has not applied the new position; without one it is
// catching up after buffering and is allowed to lag.
var maxOffsetTicks = request.IsPlaying && !PositionJumped
? TimeSpan.FromMilliseconds(context.MaxCatchUpOffset).Ticks
: maxPlaybackOffsetTicks;
if (Math.Abs(delayTicks) > maxOffsetTicks)
{
// Session not ready at all.
context.SetBuffering(session, true);
@@ -580,6 +595,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
}
var newItem = context.NextItemInQueue();
PositionJumped = newItem;
if (newItem)
{
// Send playing-queue update.
@@ -626,6 +642,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
}
var newItem = context.PreviousItemInQueue();
PositionJumped = newItem;
if (newItem)
{
// Send playing-queue update.
@@ -33,6 +33,12 @@ namespace MediaBrowser.Controller.SyncPlay
/// <value>The maximum offset error accepted, in milliseconds.</value>
long MaxPlaybackOffset { get; }
/// <summary>
/// Gets the maximum offset accepted for a session catching up after buffering, in milliseconds.
/// </summary>
/// <value>The maximum catch-up offset, in milliseconds.</value>
long MaxCatchUpOffset => 60000;
/// <summary>
/// Gets the group identifier.
/// </summary>
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
@@ -19,6 +20,128 @@ namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
public class WaitingGroupStateTests
{
[Fact]
public void Ready_PlayingSessionReportsPositionFromBeforeSeek_IsCorrected()
{
var harness = new GroupHarness();
var group = harness.Group;
group.PositionTicks = TimeSpan.FromMinutes(10).Ticks;
group.LastActivity = DateTime.UtcNow;
var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
// One member seeks half an hour in.
state.HandleRequest(
new SeekGroupRequest(TimeSpan.FromMinutes(40).Ticks),
group,
GroupStateType.Playing,
harness.Second,
CancellationToken.None);
harness.Commands.Clear();
// The other member has not applied the seek yet and reports the old position, still playing.
state.HandleRequest(
new ReadyGroupRequest(DateTime.UtcNow, TimeSpan.FromMinutes(10).Ticks, true, harness.PlaylistItemId),
group,
GroupStateType.Waiting,
harness.First,
CancellationToken.None);
// It must be seeked into position, not accepted as ready and handed a pause command
// scheduled the length of the seek into the future.
Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Seek);
Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Pause);
Assert.True(group.IsBuffering(), "session should still be considered buffering");
}
[Fact]
public void Ready_PlayingSessionRecoveringFromALongStall_IsNotSeeked()
{
var harness = new GroupHarness();
var group = harness.Group;
group.PositionTicks = TimeSpan.FromMinutes(10).Ticks;
group.LastActivity = DateTime.UtcNow;
var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
// The session reports it is buffering. No seek happens, so the group position stays put.
state.HandleRequest(
new BufferGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId),
group,
GroupStateType.Playing,
harness.First,
CancellationToken.None);
harness.Commands.Clear();
// It recovers 45 seconds later, still behind, and must be waited for rather than seeked
// forward past content it already buffered.
var behind = group.PositionTicks - TimeSpan.FromSeconds(45).Ticks;
state.HandleRequest(
new ReadyGroupRequest(DateTime.UtcNow, behind, true, harness.PlaylistItemId),
group,
GroupStateType.Waiting,
harness.First,
CancellationToken.None);
Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Seek);
}
[Fact]
public void Ready_PlayingSessionSlightlyBehindGroup_IsStillTreatedAsCatchingUp()
{
var harness = new GroupHarness();
var group = harness.Group;
// A session that is a couple of seconds behind is genuinely recovering, and the group
// is expected to wait for it rather than seek it around.
group.PositionTicks = TimeSpan.FromMinutes(30).Ticks;
group.LastActivity = DateTime.UtcNow;
group.SetBuffering(harness.First, true);
group.SetBuffering(harness.Second, true);
var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
harness.Commands.Clear();
var clientPosition = group.PositionTicks - TimeSpan.FromSeconds(2).Ticks;
state.HandleRequest(
new ReadyGroupRequest(DateTime.UtcNow, clientPosition, true, harness.PlaylistItemId),
group,
GroupStateType.Waiting,
harness.First,
CancellationToken.None);
Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Seek);
Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Pause);
}
[Fact]
public void Ready_PausedSessionOutOfPosition_IsStillCorrected()
{
var harness = new GroupHarness();
var group = harness.Group;
group.PositionTicks = TimeSpan.FromMinutes(30).Ticks;
group.LastActivity = DateTime.UtcNow;
group.SetBuffering(harness.First, true);
group.SetBuffering(harness.Second, true);
var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
harness.Commands.Clear();
state.HandleRequest(
new ReadyGroupRequest(DateTime.UtcNow, 0, false, harness.PlaylistItemId),
group,
GroupStateType.Waiting,
harness.First,
CancellationToken.None);
Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Seek);
}
[Fact]
public void Ready_ClientResumedWithLowPing_AppliesTheDefaultPingFloorInMilliseconds()
{
@@ -99,6 +222,7 @@ public class WaitingGroupStateTests
sessionManager
.Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>()))
.Callback<string, SendCommand, CancellationToken>((_, command, _) => Commands.Add(command))
.Returns(Task.CompletedTask);
sessionManager
@@ -137,5 +261,7 @@ public class WaitingGroupStateTests
public SessionInfo Second { get; }
public Guid PlaylistItemId { get; }
public List<SendCommand> Commands { get; } = new List<SendCommand>();
}
}