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();
}
}