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; /// /// 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 and the channel. /// public class CatalogService { private readonly FloatplaneApiClient _api; private readonly ILogger _logger; private readonly Func _config; private readonly SemaphoreSlim _creatorLock = new(1, 1); private readonly ConcurrentDictionary _posts = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _postDetails = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _postLocks = new(StringComparer.Ordinal); private IReadOnlyList _creators = Array.Empty(); private DateTimeOffset _creatorsFetched = DateTimeOffset.MinValue; private int _generation; public CatalogService(FloatplaneApiClient api, ILogger logger) : this(api, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration()) { } public CatalogService(FloatplaneApiClient api, ILogger logger, Func config) { _api = api; _logger = logger; _config = config; } /// True when a session cookie is configured. public bool HasSession => _api.HasSession; /// Bumped on every refresh so Jellyfin's 3-hour listing cache is bypassed. public int Generation => _generation; public DateTimeOffset LastRefresh => _creatorsFetched; /// Session state as last observed (null = not checked yet). public string? SessionState { get; private set; } public string? LastError { get; private set; } /// Cached creators (may be empty before the first refresh). public IReadOnlyList Creators => _creators; public int CachedPostListCount => _posts.Count; /// Invalidates every cache and bumps the generation. public void Invalidate() { _posts.Clear(); _postDetails.Clear(); _creatorsFetched = DateTimeOffset.MinValue; Interlocked.Increment(ref _generation); } /// Full refresh: creators + channels. Post lists are refetched lazily on browse. public async Task RefreshAsync(IProgress? 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.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); } /// /// 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. /// public async Task> 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(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)); /// /// Newest video posts of a channel ( empty = whole creator), /// up to MaxPostsPerChannel, cached for CatalogCacheMinutes. /// public async Task> 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; } /// Pages through /content/creator until posts or the end. public async Task> FetchPostsAsync(string creatorId, string channelId, int max, CancellationToken ct, string? search = null) { var list = new List(); var seen = new HashSet(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; } /// Looks a post up in the cached lists (no network). 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; } /// Post with expanded video attachments (login required). Cached for one hour. public async Task 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 Posts, DateTimeOffset Fetched); private sealed record CachedPost(PostModel Post, DateTimeOffset Fetched); }