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, }; } }