Some checks failed
release / build-release (push) Has been cancelled
- 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>
351 lines
13 KiB
C#
351 lines
13 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.Floatplane.Api;
|
|
using Jellyfin.Plugin.Floatplane.Api.Models;
|
|
using Jellyfin.Plugin.Floatplane.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.Floatplane.Channel;
|
|
|
|
/// <summary>
|
|
/// In-memory catalogue: which creators to show, their sub-channels, and the newest posts
|
|
/// per channel. Everything here is cheap public listing data; post details and delivery
|
|
/// (login required) live in <see cref="Playback.DeliveryResolver"/> and the channel.
|
|
/// </summary>
|
|
public class CatalogService
|
|
{
|
|
private readonly FloatplaneApiClient _api;
|
|
private readonly ILogger<CatalogService> _logger;
|
|
private readonly Func<PluginConfiguration> _config;
|
|
private readonly SemaphoreSlim _creatorLock = new(1, 1);
|
|
private readonly ConcurrentDictionary<string, CachedPosts> _posts = new(StringComparer.Ordinal);
|
|
private readonly ConcurrentDictionary<string, CachedPost> _postDetails = new(StringComparer.Ordinal);
|
|
private readonly ConcurrentDictionary<string, SemaphoreSlim> _postLocks = new(StringComparer.Ordinal);
|
|
|
|
private IReadOnlyList<CreatorModel> _creators = Array.Empty<CreatorModel>();
|
|
private DateTimeOffset _creatorsFetched = DateTimeOffset.MinValue;
|
|
private int _generation;
|
|
|
|
public CatalogService(FloatplaneApiClient api, ILogger<CatalogService> logger)
|
|
: this(api, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration())
|
|
{
|
|
}
|
|
|
|
public CatalogService(FloatplaneApiClient api, ILogger<CatalogService> logger, Func<PluginConfiguration> config)
|
|
{
|
|
_api = api;
|
|
_logger = logger;
|
|
_config = config;
|
|
}
|
|
|
|
/// <summary>True when a session cookie is configured.</summary>
|
|
public bool HasSession => _api.HasSession;
|
|
|
|
/// <summary>Bumped on every refresh so Jellyfin's 3-hour listing cache is bypassed.</summary>
|
|
public int Generation => _generation;
|
|
|
|
public DateTimeOffset LastRefresh => _creatorsFetched;
|
|
|
|
/// <summary>Session state as last observed (null = not checked yet).</summary>
|
|
public string? SessionState { get; private set; }
|
|
|
|
public string? LastError { get; private set; }
|
|
|
|
/// <summary>Cached creators (may be empty before the first refresh).</summary>
|
|
public IReadOnlyList<CreatorModel> Creators => _creators;
|
|
|
|
public int CachedPostListCount => _posts.Count;
|
|
|
|
/// <summary>Invalidates every cache and bumps the generation.</summary>
|
|
public void Invalidate()
|
|
{
|
|
_posts.Clear();
|
|
_postDetails.Clear();
|
|
_creatorsFetched = DateTimeOffset.MinValue;
|
|
Interlocked.Increment(ref _generation);
|
|
}
|
|
|
|
/// <summary>Full refresh: creators + channels. Post lists are refetched lazily on browse.</summary>
|
|
public async Task RefreshAsync(IProgress<double>? progress, CancellationToken ct)
|
|
{
|
|
_posts.Clear();
|
|
_postDetails.Clear();
|
|
await GetCreatorsAsync(true, ct).ConfigureAwait(false);
|
|
progress?.Report(50);
|
|
|
|
// Warm the first page of every channel so the first browse is instant.
|
|
var creators = _creators;
|
|
var total = Math.Max(1, creators.Sum(c => Math.Max(1, c.Channels.Count)));
|
|
var done = 0;
|
|
foreach (var creator in creators)
|
|
{
|
|
var channelIds = creator.Channels.Count > 0 ? creator.Channels.Select(c => c.Id).ToList() : new List<string> { string.Empty };
|
|
foreach (var channelId in channelIds)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
try
|
|
{
|
|
await GetPostsAsync(creator.Id, channelId, ct).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogWarning(ex, "Could not warm posts for {Creator}/{Channel}", creator.Urlname, channelId);
|
|
}
|
|
|
|
done++;
|
|
progress?.Report(50 + (50.0 * done / total));
|
|
}
|
|
}
|
|
|
|
Interlocked.Increment(ref _generation);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creators to show: the account's subscriptions (when a valid session exists) plus the
|
|
/// configured extra url-names. Public data is used wherever possible so browsing works
|
|
/// without a session.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<CreatorModel>> GetCreatorsAsync(bool force, CancellationToken ct)
|
|
{
|
|
var ttl = TimeSpan.FromMinutes(Math.Max(1, _config().CatalogCacheMinutes));
|
|
if (!force && _creators.Count > 0 && DateTimeOffset.UtcNow - _creatorsFetched < ttl)
|
|
{
|
|
return _creators;
|
|
}
|
|
|
|
await _creatorLock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (!force && _creators.Count > 0 && DateTimeOffset.UtcNow - _creatorsFetched < ttl)
|
|
{
|
|
return _creators;
|
|
}
|
|
|
|
var result = new Dictionary<string, CreatorModel>(StringComparer.Ordinal);
|
|
var cfg = _config();
|
|
|
|
if (_api.HasSession)
|
|
{
|
|
try
|
|
{
|
|
var subs = await _api.GetSubscriptionsAsync(ct).ConfigureAwait(false);
|
|
SessionState = "ok";
|
|
LastError = null;
|
|
foreach (var creatorId in subs.Select(s => s.Creator).Where(id => !string.IsNullOrEmpty(id)).Distinct(StringComparer.Ordinal))
|
|
{
|
|
try
|
|
{
|
|
var creator = await _api.GetCreatorAsync(creatorId, ct).ConfigureAwait(false);
|
|
result[creator.Id] = creator;
|
|
}
|
|
catch (FloatplaneApiException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Could not load subscribed creator {Id}", creatorId);
|
|
}
|
|
}
|
|
}
|
|
catch (FloatplaneAuthException ex)
|
|
{
|
|
SessionState = "invalid";
|
|
LastError = ex.Message;
|
|
_logger.LogWarning("Floatplane session cookie rejected ({Message}); paste a fresh sails.sid in the plugin settings", ex.Message);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
SessionState = "none";
|
|
}
|
|
|
|
var extra = (cfg.ExtraCreators ?? string.Empty)
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Where(n => n.Length > 0)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
if (extra.Count > 0)
|
|
{
|
|
try
|
|
{
|
|
var named = await _api.GetCreatorsByUrlNameAsync(extra, ct).ConfigureAwait(false);
|
|
foreach (var creator in named)
|
|
{
|
|
result.TryAdd(creator.Id, creator);
|
|
}
|
|
}
|
|
catch (FloatplaneApiException ex)
|
|
{
|
|
LastError = ex.Message;
|
|
_logger.LogWarning(ex, "Could not resolve extra creators {Names}", string.Join(",", extra));
|
|
}
|
|
}
|
|
|
|
// creator/named and creator/info both include channels; fill in if missing.
|
|
var missing = result.Values.Where(c => c.Channels.Count == 0).Select(c => c.Id).ToList();
|
|
if (missing.Count > 0)
|
|
{
|
|
try
|
|
{
|
|
var channels = await _api.GetCreatorChannelsAsync(missing, ct).ConfigureAwait(false);
|
|
foreach (var ch in channels)
|
|
{
|
|
if (ch.Creator != null && result.TryGetValue(ch.Creator, out var creator))
|
|
{
|
|
creator.Channels.Add(ch);
|
|
}
|
|
}
|
|
}
|
|
catch (FloatplaneApiException ex)
|
|
{
|
|
_logger.LogDebug(ex, "channels/list failed");
|
|
}
|
|
}
|
|
|
|
foreach (var creator in result.Values)
|
|
{
|
|
creator.Channels = creator.Channels.OrderBy(c => c.Order).ThenBy(c => c.Title, StringComparer.OrdinalIgnoreCase).ToList();
|
|
}
|
|
|
|
_creators = result.Values.OrderBy(c => c.Title, StringComparer.OrdinalIgnoreCase).ToList();
|
|
_creatorsFetched = DateTimeOffset.UtcNow;
|
|
_logger.LogInformation("Floatplane catalogue: {Count} creators ({Session})", _creators.Count, SessionState);
|
|
return _creators;
|
|
}
|
|
finally
|
|
{
|
|
_creatorLock.Release();
|
|
}
|
|
}
|
|
|
|
public CreatorModel? FindCreator(string creatorId) => _creators.FirstOrDefault(c => string.Equals(c.Id, creatorId, StringComparison.Ordinal));
|
|
|
|
/// <summary>
|
|
/// Newest video posts of a channel (<paramref name="channelId"/> empty = whole creator),
|
|
/// up to <c>MaxPostsPerChannel</c>, cached for <c>CatalogCacheMinutes</c>.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<PostModel>> GetPostsAsync(string creatorId, string channelId, CancellationToken ct)
|
|
{
|
|
var key = creatorId + "|" + channelId;
|
|
var ttl = TimeSpan.FromMinutes(Math.Max(1, _config().CatalogCacheMinutes));
|
|
if (_posts.TryGetValue(key, out var cached) && DateTimeOffset.UtcNow - cached.Fetched < ttl)
|
|
{
|
|
return cached.Posts;
|
|
}
|
|
|
|
var max = Math.Clamp(_config().MaxPostsPerChannel, 20, 2000);
|
|
var posts = await FetchPostsAsync(creatorId, channelId, max, ct).ConfigureAwait(false);
|
|
_posts[key] = new CachedPosts(posts, DateTimeOffset.UtcNow);
|
|
return posts;
|
|
}
|
|
|
|
/// <summary>Pages through /content/creator until <paramref name="max"/> posts or the end.</summary>
|
|
public async Task<IReadOnlyList<PostModel>> FetchPostsAsync(string creatorId, string channelId, int max, CancellationToken ct, string? search = null)
|
|
{
|
|
var list = new List<PostModel>();
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
// Without a session Floatplane reports every post as inaccessible, so the
|
|
// "locked" filter only makes sense once we are logged in.
|
|
var showLocked = _config().ShowLockedPosts || !_api.HasSession;
|
|
for (var offset = 0; list.Count < max; offset += 20)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var page = await _api.ListPostsAsync(
|
|
new PostQuery
|
|
{
|
|
CreatorId = creatorId,
|
|
ChannelId = string.IsNullOrEmpty(channelId) ? null : channelId,
|
|
Limit = 20,
|
|
FetchAfter = offset,
|
|
HasVideo = true,
|
|
Search = search
|
|
},
|
|
ct).ConfigureAwait(false);
|
|
if (page.Count == 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
foreach (var post in page)
|
|
{
|
|
if (!post.HasVideo || !seen.Add(post.Id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!post.IsAccessible && !showLocked)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
list.Add(post);
|
|
if (list.Count >= max)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (page.Count < 20)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
return list;
|
|
}
|
|
|
|
/// <summary>Looks a post up in the cached lists (no network).</summary>
|
|
public PostModel? FindCachedPost(string postId)
|
|
{
|
|
foreach (var entry in _posts.Values)
|
|
{
|
|
var hit = entry.Posts.FirstOrDefault(p => string.Equals(p.Id, postId, StringComparison.Ordinal));
|
|
if (hit != null)
|
|
{
|
|
return hit;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Post with expanded video attachments (login required). Cached for one hour.</summary>
|
|
public async Task<PostModel> GetPostDetailsAsync(string postId, CancellationToken ct)
|
|
{
|
|
if (_postDetails.TryGetValue(postId, out var cached) && DateTimeOffset.UtcNow - cached.Fetched < TimeSpan.FromHours(1))
|
|
{
|
|
return cached.Post;
|
|
}
|
|
|
|
var gate = _postLocks.GetOrAdd(postId, _ => new SemaphoreSlim(1, 1));
|
|
await gate.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (_postDetails.TryGetValue(postId, out cached) && DateTimeOffset.UtcNow - cached.Fetched < TimeSpan.FromHours(1))
|
|
{
|
|
return cached.Post;
|
|
}
|
|
|
|
var post = await _api.GetPostAsync(postId, ct).ConfigureAwait(false);
|
|
SessionState = "ok";
|
|
_postDetails[postId] = new CachedPost(post, DateTimeOffset.UtcNow);
|
|
return post;
|
|
}
|
|
catch (FloatplaneAuthException ex)
|
|
{
|
|
SessionState = ex.ErrorName == "notSubscribedError" ? "ok" : "invalid";
|
|
LastError = ex.Message;
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
gate.Release();
|
|
}
|
|
}
|
|
|
|
private sealed record CachedPosts(IReadOnlyList<PostModel> Posts, DateTimeOffset Fetched);
|
|
|
|
private sealed record CachedPost(PostModel Post, DateTimeOffset Fetched);
|
|
}
|