Files
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 09:42:27 +01:00

183 lines
6.6 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.Chaosflix.Channel;
/// <summary>
/// Keeps the watch state of one talk in sync across the copies the channel
/// hands out.
/// </summary>
/// <remarks>
/// Jellyfin stores a channel item as one library item with exactly one parent,
/// so a talk listed in several folders needs one id per folder (#15) and ends up
/// as several library items — each with its own user data. Marking a talk
/// unwatched or clearing its position then only affects the copy in front of the
/// user (#53). This mirrors played state and resume position from the copy that
/// was saved to its siblings.
/// </remarks>
public sealed class ChaosflixUserDataMirror : IHostedService
{
private readonly IUserDataManager _userDataManager;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly ILogger<ChaosflixUserDataMirror> _logger;
// Jellyfin raises UserDataSaved from inside SaveUserData, so a mirrored save
// comes back as another event; the copies it is about are in here while it runs.
private readonly ConcurrentDictionary<(Guid User, Guid Item), byte> _mirroring = new();
/// <summary>
/// Initializes a new instance of the <see cref="ChaosflixUserDataMirror"/> class.
/// </summary>
/// <param name="userDataManager">Source of the save events and sink for the mirrored state.</param>
/// <param name="libraryManager">Used to find the other copies of a talk.</param>
/// <param name="userManager">Resolves the user a save belongs to.</param>
/// <param name="logger">Logger.</param>
public ChaosflixUserDataMirror(
IUserDataManager userDataManager,
ILibraryManager libraryManager,
IUserManager userManager,
ILogger<ChaosflixUserDataMirror> logger)
{
_userDataManager = userDataManager;
_libraryManager = libraryManager;
_userManager = userManager;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved += OnUserDataSaved;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= OnUserDataSaved;
return Task.CompletedTask;
}
internal void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
{
try
{
Mirror(e);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not mirror the watch state of {Item}", e.Item?.Name);
}
}
internal void Mirror(UserDataSaveEventArgs e)
{
var item = e.Item;
var source = e.UserData;
if (item is null || source is null || !IsWatchState(e.SaveReason))
{
return;
}
var eventGuid = ChaosflixChannel.EventGuidOf(item.ExternalId);
if (eventGuid 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, eventGuid))
{
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);
}
finally
{
_mirroring.TryRemove((e.UserId, sibling.Id), out _);
}
}
}
// Import is a bulk reason and carries no user action; the rest is what a
// client does when it plays a talk or toggles its watched state.
private static bool IsWatchState(UserDataSaveReason reason) => reason is
UserDataSaveReason.PlaybackStart or
UserDataSaveReason.PlaybackProgress or
UserDataSaveReason.PlaybackFinished or
UserDataSaveReason.TogglePlayed or
UserDataSaveReason.UpdateUserData;
// A save whose state already matches produces no save of its own, which is
// what stops a mirrored save from travelling back and forth.
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 eventGuid)
{
// Looked up by name within the channel rather than by scanning every
// channel item: the scopes a talk can appear under are open-ended
// (related-<guid> exists per talk), so its other ids cannot be enumerated.
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 copies of {Item}", item.Name);
return Array.Empty<BaseItem>();
}
return candidates
.Where(candidate =>
!candidate.Id.Equals(item.Id) &&
string.Equals(ChaosflixChannel.EventGuidOf(candidate.ExternalId), eventGuid, StringComparison.Ordinal))
// Through the library cache, not as the query materialised them: user
// data is read off the BaseItem instance the cache holds, so saving
// against a fresh instance reaches the database but leaves what the
// server serves stale until a restart.
.Select(candidate => _libraryManager.GetItemById(candidate.Id) ?? candidate);
}
}