Some checks failed
release / build-release (push) Has been cancelled
- per-creator "All videos" folder (release order across sub-channels); sub-channel copies use cpost: ids and UserDataMirror keeps played/resume state in sync - SortNameUpdater forces release-order sort names so every client lists newest first by default - official Floatplane icon as channel image (Primary only — a channel Thumb hid video thumbnails in Continue Watching) and as 16:9 plugin thumbnail; one-time image replacement on upgrade - OAuth device-flow login (FloatplaneAuthService + settings UI); needs a Floatplane client id; Turnstile verified domain-bound so password login inside Jellyfin is impossible - generic install.sh (linuxserver / official image / native), thumb.png in package + manifest - README rewritten for a general audience, doc/usage.md with screenshots, 60 tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
157 lines
5.3 KiB
C#
157 lines
5.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// A post is listed twice — under "All videos" (<c>post:…</c>) and under its sub-channel
|
|
/// (<c>cpost:…</c>) — 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).
|
|
/// </summary>
|
|
public sealed class UserDataMirror : IHostedService
|
|
{
|
|
private readonly IUserDataManager _userDataManager;
|
|
private readonly ILibraryManager _libraryManager;
|
|
private readonly IUserManager _userManager;
|
|
private readonly ILogger<UserDataMirror> _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<UserDataMirror> logger)
|
|
{
|
|
_userDataManager = userDataManager;
|
|
_libraryManager = libraryManager;
|
|
_userManager = userManager;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
_userDataManager.UserDataSaved += OnUserDataSaved;
|
|
_logger.LogDebug("Floatplane user-data mirror active");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<BaseItem> Siblings(BaseItem item, string postId)
|
|
{
|
|
var query = new InternalItemsQuery
|
|
{
|
|
Name = item.Name,
|
|
ChannelIds = item.ChannelId == Guid.Empty ? Array.Empty<Guid>() : new[] { item.ChannelId }
|
|
};
|
|
|
|
IReadOnlyList<BaseItem> 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<BaseItem>();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|