Synced rows only ever had the Played flag set. Jellyfin computes Next Up from LastPlayedDate on the member's own row and Continue Watching from the resume position, so a member watching through the shared account got the tick on each episode but their Next Up never advanced. Members are now written the way BaseItem.MarkPlayed/MarkUnplayed write: date and position included. Rows the old version ticked without a date are repaired the next time the item syncs. PlaybackFinished was also treated as an unwatched toggle. Jellyfin raises it on every stop, not just completion (and on 10.11 PlaybackStart resets Played to false first), so a stop halfway through on the shared account cleared members' own watched state whenever Sync unwatched was on. Playback-derived reasons (PlaybackFinished, PlaybackProgress, UpdateUserData) now only ever mirror "watched"; TogglePlayed and Import remain explicit and mirror either way. PlaybackProgress is acted on so the tick lands as soon as the completion threshold is crossed, including for clients that never report a stop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
210 lines
7.6 KiB
C#
210 lines
7.6 KiB
C#
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using MediaBrowser.Controller.Library;
|
|
using MediaBrowser.Model.Entities;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.WatchedTogether.Services;
|
|
|
|
/// <summary>
|
|
/// Propagates played state from a shared account to each of its members, one way.
|
|
/// </summary>
|
|
public sealed class WatchedStateSyncService : IHostedService, IDisposable
|
|
{
|
|
private readonly IUserDataManager _userDataManager;
|
|
private readonly IUserManager _userManager;
|
|
private readonly IGroupService _groupService;
|
|
private readonly ILogger<WatchedStateSyncService> _logger;
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="WatchedStateSyncService"/> class.
|
|
/// </summary>
|
|
/// <param name="userDataManager">The user data manager.</param>
|
|
/// <param name="userManager">The user manager.</param>
|
|
/// <param name="groupService">The group service.</param>
|
|
/// <param name="logger">The logger.</param>
|
|
public WatchedStateSyncService(
|
|
IUserDataManager userDataManager,
|
|
IUserManager userManager,
|
|
IGroupService groupService,
|
|
ILogger<WatchedStateSyncService> logger)
|
|
{
|
|
_userDataManager = userDataManager;
|
|
_userManager = userManager;
|
|
_groupService = groupService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
_userDataManager.UserDataSaved += OnUserDataSaved;
|
|
_logger.LogInformation("Watched Together sync started");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
_userDataManager.UserDataSaved -= OnUserDataSaved;
|
|
_logger.LogInformation("Watched Together sync stopped");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_userDataManager.UserDataSaved -= OnUserDataSaved;
|
|
_disposed = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mirrors a shared account's played state onto its members.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// No loop guard is needed. Writing to a member raises this event again with that member's id,
|
|
/// which is not a shared account id, so the handler returns immediately. The
|
|
/// <c>Played</c> equality check below suppresses redundant writes on top of that.
|
|
/// </para>
|
|
/// <para>
|
|
/// A member is written the same way Jellyfin's own <c>BaseItem.MarkPlayed</c> and
|
|
/// <c>MarkUnplayed</c> write, not just the <c>Played</c> flag. Next Up is driven entirely by
|
|
/// <c>LastPlayedDate</c> on the member's own row, so a tick without a date leaves the member's
|
|
/// Next Up stuck; and a stale resume position would keep the item in Continue Watching.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
|
|
{
|
|
if (e?.UserData is null || e.Item is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var played = e.UserData.Played;
|
|
if (!IsWatchedStateChange(e.SaveReason, played))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var group = _groupService.GetGroupForSharedUser(e.UserId);
|
|
if (group is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!played && !group.SyncUnwatched)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var member in _groupService.GetEligibleMembers(group))
|
|
{
|
|
try
|
|
{
|
|
var data = _userDataManager.GetUserData(member, e.Item);
|
|
if (data is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Rows written by earlier versions carry the tick but no date; give those a date
|
|
// the next time the item syncs rather than skipping them as already in sync.
|
|
if (data.Played == played && (!played || data.LastPlayedDate.HasValue))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (played)
|
|
{
|
|
data.Played = true;
|
|
data.PlaybackPositionTicks = 0;
|
|
data.LastPlayedDate = e.UserData.LastPlayedDate ?? DateTime.UtcNow;
|
|
|
|
if (group.SyncPlayCount && data.PlayCount < 1)
|
|
{
|
|
data.PlayCount = 1;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Same as Jellyfin's MarkUnplayed, except the play count is left alone: it is
|
|
// documented as never decreasing.
|
|
data.Played = false;
|
|
data.PlaybackPositionTicks = 0;
|
|
data.LastPlayedDate = null;
|
|
}
|
|
|
|
_userDataManager.SaveUserData(
|
|
member,
|
|
e.Item,
|
|
data,
|
|
UserDataSaveReason.TogglePlayed,
|
|
CancellationToken.None);
|
|
|
|
_logger.LogDebug(
|
|
"Synced played={Played} for {ItemName} to member {MemberUsername}",
|
|
played,
|
|
e.Item.Name,
|
|
member.Username);
|
|
}
|
|
#pragma warning disable CA1031 // One member failing must not stop the rest from syncing.
|
|
catch (Exception ex)
|
|
#pragma warning restore CA1031
|
|
{
|
|
_logger.LogError(
|
|
ex,
|
|
"Failed to sync played state for {ItemName} to member {MemberId}",
|
|
e.Item.Name,
|
|
member.Id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decides whether a save represents a change in watched state worth mirroring.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <see cref="UserDataSaveReason.TogglePlayed"/> and <see cref="UserDataSaveReason.Import"/>
|
|
/// are explicit: someone set the flag, so whatever it says is mirrored, unwatched included.
|
|
/// </para>
|
|
/// <para>
|
|
/// The rest only ever mean "watched" when <paramref name="played"/> is true. Jellyfin raises
|
|
/// <see cref="UserDataSaveReason.PlaybackFinished"/> on every stop, not just on completion, so
|
|
/// a stop before the completion threshold leaves <c>Played</c> false without anybody having
|
|
/// marked anything unwatched - and on 10.11 <c>PlaybackStart</c> resets it to false as well.
|
|
/// Mirroring that would clear members' own watched state. Likewise
|
|
/// <see cref="UserDataSaveReason.UpdateUserData"/> is a partial update (a favourite toggle
|
|
/// arrives with the same reason) where a false flag need not mean a change at all.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="UserDataSaveReason.PlaybackProgress"/> is included so the tick lands as soon as
|
|
/// the completion threshold is crossed, and still lands for clients that never report a stop.
|
|
/// The equality check in the handler keeps the remaining progress ticks write-free.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="reason">Why the user data was saved.</param>
|
|
/// <param name="played">The played flag on the saved user data.</param>
|
|
/// <returns><c>true</c> if the save should be mirrored to members.</returns>
|
|
private static bool IsWatchedStateChange(UserDataSaveReason reason, bool played)
|
|
{
|
|
return reason switch
|
|
{
|
|
UserDataSaveReason.TogglePlayed or UserDataSaveReason.Import => true,
|
|
UserDataSaveReason.PlaybackFinished
|
|
or UserDataSaveReason.PlaybackProgress
|
|
or UserDataSaveReason.UpdateUserData => played,
|
|
_ => false,
|
|
};
|
|
}
|
|
}
|