using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Floatplane.Channel; /// /// A post is listed twice — under "All videos" (post:…) and under its sub-channel /// (cpost:…) — because Jellyfin gives a channel item exactly one parent. Jellyfin /// therefore keeps two sets of user data; this service copies played / resume state from /// one copy to the other whenever it changes (pattern borrowed from chaosflix-jellyfin). /// public sealed class UserDataMirror : IHostedService { private readonly IUserDataManager _userDataManager; private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; private readonly ILogger _logger; // UserDataSaved is raised from inside SaveUserData, so our own mirrored save comes // back as an event; the copies being written are tracked here while it runs. private readonly ConcurrentDictionary<(Guid User, Guid Item), byte> _mirroring = new(); public UserDataMirror(IUserDataManager userDataManager, ILibraryManager libraryManager, IUserManager userManager, ILogger logger) { _userDataManager = userDataManager; _libraryManager = libraryManager; _userManager = userManager; _logger = logger; } /// public Task StartAsync(CancellationToken cancellationToken) { _userDataManager.UserDataSaved += OnUserDataSaved; _logger.LogDebug("Floatplane user-data mirror active"); return Task.CompletedTask; } /// public Task StopAsync(CancellationToken cancellationToken) { _userDataManager.UserDataSaved -= OnUserDataSaved; return Task.CompletedTask; } private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) { try { Mirror(e); } catch (Exception ex) { _logger.LogWarning(ex, "Could not mirror watch state of {Item}", e.Item?.Name); } } private void Mirror(UserDataSaveEventArgs e) { var item = e.Item; var source = e.UserData; if (item is null || source is null || !IsWatchState(e.SaveReason)) { return; } var postId = FloatplaneChannel.PostIdOf(item.ExternalId); if (postId is null || _mirroring.ContainsKey((e.UserId, item.Id))) { return; } var user = _userManager.GetUserById(e.UserId); if (user is null) { return; } foreach (var sibling in Siblings(item, postId)) { var target = _userDataManager.GetUserData(user, sibling); if (target is null || !Differs(source, target)) { continue; } target.Played = source.Played; target.PlaybackPositionTicks = source.PlaybackPositionTicks; target.PlayCount = source.PlayCount; target.LastPlayedDate = source.LastPlayedDate; if (!_mirroring.TryAdd((e.UserId, sibling.Id), 0)) { continue; } try { _userDataManager.SaveUserData(user, sibling, target, UserDataSaveReason.UpdateUserData, CancellationToken.None); _logger.LogDebug("Mirrored watch state of {Post} to its other copy", postId); } finally { _mirroring.TryRemove((e.UserId, sibling.Id), out _); } } } private static bool IsWatchState(UserDataSaveReason reason) => reason is UserDataSaveReason.PlaybackStart or UserDataSaveReason.PlaybackProgress or UserDataSaveReason.PlaybackFinished or UserDataSaveReason.TogglePlayed or UserDataSaveReason.UpdateUserData; private static bool Differs(UserItemData source, UserItemData target) => source.Played != target.Played || source.PlaybackPositionTicks != target.PlaybackPositionTicks || source.PlayCount != target.PlayCount || source.LastPlayedDate != target.LastPlayedDate; private IEnumerable Siblings(BaseItem item, string postId) { var query = new InternalItemsQuery { Name = item.Name, ChannelIds = item.ChannelId == Guid.Empty ? Array.Empty() : new[] { item.ChannelId } }; IReadOnlyList candidates; try { candidates = _libraryManager.GetItemList(query); } catch (Exception ex) { _logger.LogWarning(ex, "Could not look up the other copy of {Item}", item.Name); return Array.Empty(); } return candidates .Where(c => !c.Id.Equals(item.Id) && string.Equals(FloatplaneChannel.PostIdOf(c.ExternalId), postId, StringComparison.Ordinal)) // Through the library cache so the instance the server serves gets the new data too. .Select(c => _libraryManager.GetItemById(c.Id) ?? c) .ToList(); } }