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;
///
/// Propagates played state from a shared account to each of its members, one way.
///
public sealed class WatchedStateSyncService : IHostedService, IDisposable
{
private readonly IUserDataManager _userDataManager;
private readonly IUserManager _userManager;
private readonly IGroupService _groupService;
private readonly ILogger _logger;
private bool _disposed;
///
/// Initializes a new instance of the class.
///
/// The user data manager.
/// The user manager.
/// The group service.
/// The logger.
public WatchedStateSyncService(
IUserDataManager userDataManager,
IUserManager userManager,
IGroupService groupService,
ILogger logger)
{
_userDataManager = userDataManager;
_userManager = userManager;
_groupService = groupService;
_logger = logger;
}
///
public Task StartAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved += OnUserDataSaved;
_logger.LogInformation("Watched Together sync started");
return Task.CompletedTask;
}
///
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= OnUserDataSaved;
_logger.LogInformation("Watched Together sync stopped");
return Task.CompletedTask;
}
///
public void Dispose()
{
if (_disposed)
{
return;
}
_userDataManager.UserDataSaved -= OnUserDataSaved;
_disposed = true;
}
///
/// Mirrors a shared account's played state onto its members.
///
///
///
/// 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
/// Played equality check below suppresses redundant writes on top of that.
///
///
/// A member is written the same way Jellyfin's own BaseItem.MarkPlayed and
/// MarkUnplayed write, not just the Played flag. Next Up is driven entirely by
/// LastPlayedDate 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.
///
///
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);
}
}
}
///
/// Decides whether a save represents a change in watched state worth mirroring.
///
///
///
/// and
/// are explicit: someone set the flag, so whatever it says is mirrored, unwatched included.
///
///
/// The rest only ever mean "watched" when is true. Jellyfin raises
/// on every stop, not just on completion, so
/// a stop before the completion threshold leaves Played false without anybody having
/// marked anything unwatched - and on 10.11 PlaybackStart resets it to false as well.
/// Mirroring that would clear members' own watched state. Likewise
/// is a partial update (a favourite toggle
/// arrives with the same reason) where a false flag need not mean a change at all.
///
///
/// 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.
///
///
/// Why the user data was saved.
/// The played flag on the saved user data.
/// true if the save should be mirrored to members.
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,
};
}
}