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. /// private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) { if (e?.UserData is null || e.Item is null) { return; } // UserDataSaved fires constantly during playback (progress ticks); only act on the reasons // that actually represent a change in watched state. if (e.SaveReason is not (UserDataSaveReason.PlaybackFinished or UserDataSaveReason.TogglePlayed or UserDataSaveReason.Import)) { return; } var group = _groupService.GetGroupForSharedUser(e.UserId); if (group is null) { return; } var played = e.UserData.Played; if (!played && !group.SyncUnwatched) { return; } foreach (var member in _groupService.GetEligibleMembers(group)) { try { var data = _userDataManager.GetUserData(member, e.Item); if (data is null || data.Played == played) { continue; } data.Played = played; if (group.SyncPlayCount && played && data.PlayCount < 1) { data.PlayCount = 1; } _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); } } } }