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

Don't advance rewatch Next Up past a barely started episode

Original-merge: d575c6f1f9

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

Backported-by: Cody Robibero <cody@robibe.ro>
This commit is contained in:
Shadowghost authored and Cody Robibero committed 2026-10-05 19:18:48 -04:00
1 parent 0ce7acdb61
commit 6983363761
2 files changed
+130 -2

No files matched your search

@@ -846,7 +846,13 @@ namespace Emby.Server.Implementations.Session
var data = _userDataManager.GetUserData(user, item);
data.PlayCount++;
data.LastPlayedDate = DateTime.UtcNow;
// Re-watching a played item only counts once a progress or stop report gets past the resume threshold,
// otherwise rewatch Next Up moves on from an episode that was barely started
if (!data.Played || !item.SupportsPositionTicksResume)
{
data.LastPlayedDate = DateTime.UtcNow;
}
if (item.SupportsPlayedStatus && !item.SupportsPositionTicksResume)
{
@@ -972,7 +978,12 @@ namespace Emby.Server.Implementations.Session
if (positionTicks.HasValue)
{
_userDataManager.UpdatePlayState(item, data, positionTicks.Value);
var playedToCompletion = _userDataManager.UpdatePlayState(item, data, positionTicks.Value);
if (playedToCompletion || data.PlaybackPositionTicks > 0)
{
data.LastPlayedDate = DateTime.UtcNow;
}
changed = true;
}
@@ -1174,6 +1185,10 @@ namespace Emby.Server.Implementations.Session
if (positionTicks.HasValue)
{
playedToCompletion = _userDataManager.UpdatePlayState(item, data, positionTicks.Value);
if (playedToCompletion || data.PlaybackPositionTicks > 0)
{
data.LastPlayedDate = DateTime.UtcNow;
}
}
else
{
@@ -1181,6 +1196,7 @@ namespace Emby.Server.Implementations.Session
data.PlayCount++;
data.Played = item.SupportsPlayedStatus;
data.PlaybackPositionTicks = 0;
data.LastPlayedDate = DateTime.UtcNow;
playedToCompletion = true;
}
@@ -0,0 +1,112 @@
using System;
using System.Reflection;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.SessionManager;
[Collection("LibraryManagerTests")]
public sealed class RewatchPlayStateTests : IDisposable
{
private const long ResumeThresholdTicks = 100 * TimeSpan.TicksPerSecond;
private static readonly DateTime _previousPlayDate = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private readonly ILibraryManager? _previousLibraryManager;
public RewatchPlayStateTests()
{
_previousLibraryManager = BaseItem.LibraryManager;
// Resolves the (empty) alternate versions of the episode when the stop propagates its played state
BaseItem.LibraryManager = Mock.Of<ILibraryManager>();
}
public void Dispose()
{
BaseItem.LibraryManager = _previousLibraryManager!;
}
[Theory]
[InlineData(3 * TimeSpan.TicksPerSecond, false)]
[InlineData(600 * TimeSpan.TicksPerSecond, true)]
public async Task RewatchOfPlayedEpisode_OnlyCountsPastResumeThreshold(long stopPositionTicks, bool expectDateUpdated)
{
var data = new UserItemData { Key = "episode", Played = true, LastPlayedDate = _previousPlayDate };
await using var sessionManager = CreateSessionManager(data);
var user = new User("test", "default", "default");
var episode = new Episode { Id = Guid.NewGuid() };
Invoke(sessionManager, "OnPlaybackStart", user, episode);
Assert.Equal(_previousPlayDate, data.LastPlayedDate);
Invoke(sessionManager, "OnPlaybackStopped", user, episode, stopPositionTicks, false);
Assert.True(data.Played);
Assert.Equal(expectDateUpdated, data.LastPlayedDate > _previousPlayDate);
}
[Fact]
public async Task PlaybackStartOfUnplayedEpisode_UpdatesLastPlayedDate()
{
var data = new UserItemData { Key = "episode", LastPlayedDate = _previousPlayDate };
await using var sessionManager = CreateSessionManager(data);
Invoke(sessionManager, "OnPlaybackStart", new User("test", "default", "default"), new Episode { Id = Guid.NewGuid() });
Assert.True(data.LastPlayedDate > _previousPlayDate);
}
private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(UserItemData data)
{
var userDataManager = new Mock<IUserDataManager>();
userDataManager
.Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
.Returns(data);
userDataManager
.Setup(m => m.UpdatePlayState(It.IsAny<BaseItem>(), It.IsAny<UserItemData>(), It.IsAny<long?>()))
.Callback<BaseItem, UserItemData, long?>((_, d, position) => d.PlaybackPositionTicks = position >= ResumeThresholdTicks ? position.Value : 0)
.Returns(false);
return new Emby.Server.Implementations.Session.SessionManager(
NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance,
Mock.Of<IEventManager>(),
userDataManager.Object,
Mock.Of<IServerConfigurationManager>(),
Mock.Of<ILibraryManager>(),
Mock.Of<IUserManager>(),
Mock.Of<IMusicManager>(),
Mock.Of<IDtoService>(),
Mock.Of<IImageProcessor>(),
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>());
}
private static void Invoke(Emby.Server.Implementations.Session.SessionManager sessionManager, string name, params object?[] args)
{
var parameterTypes = Array.ConvertAll(args, a => a!.GetType());
parameterTypes[1] = typeof(BaseItem);
if (args.Length > 2)
{
parameterTypes[2] = typeof(long?);
}
typeof(Emby.Server.Implementations.Session.SessionManager)
.GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic, parameterTypes)!
.Invoke(sessionManager, args);
}
}