Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
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>
This commit is contained in:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

View File

@@ -0,0 +1,207 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Chaosflix.Api;
/// <summary>
/// Simple in-memory cache with TTL expiration, a capacity bound and stampede protection.
/// </summary>
public class MemoryCache
{
/// <summary>
/// Default number of entries kept before the soonest-expiring ones are evicted.
/// </summary>
internal const int DefaultCapacity = 256;
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private readonly ConcurrentDictionary<string, Gate> _locks = new();
private readonly TimeProvider _timeProvider;
private readonly int _capacity;
private long _hits;
private long _misses;
/// <summary>
/// Initializes a new instance of the <see cref="MemoryCache"/> class.
/// </summary>
/// <param name="capacity">Maximum number of entries to keep.</param>
/// <param name="timeProvider">Clock used for expiry, for tests.</param>
public MemoryCache(int capacity = DefaultCapacity, TimeProvider? timeProvider = null)
{
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
_capacity = capacity;
_timeProvider = timeProvider ?? TimeProvider.System;
}
internal int Count => _cache.Count;
internal int LockCount => _locks.Count;
internal int Capacity => _capacity;
internal long Hits => Interlocked.Read(ref _hits);
internal long Misses => Interlocked.Read(ref _misses);
/// <summary>
/// Counts the entries that would still be served. Expired entries are only dropped
/// when something is written, so <see cref="Count"/> can outrun what is usable.
/// </summary>
internal int CountLive(Func<string, bool>? predicate = null)
{
var now = _timeProvider.GetUtcNow();
return _cache.Count(entry =>
entry.Value.ExpiresAt > now && (predicate is null || predicate(entry.Key)));
}
/// <summary>
/// Gets or creates a cached value.
/// </summary>
public async Task<T> GetOrCreateAsync<T>(string key, TimeSpan ttl, Func<CancellationToken, Task<T>> factory, CancellationToken cancellationToken)
{
if (TryGet<T>(key, out var hit))
{
Interlocked.Increment(ref _hits);
return hit;
}
var gate = RentGate(key);
try
{
await gate.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (TryGet<T>(key, out hit))
{
Interlocked.Increment(ref _hits);
return hit;
}
Interlocked.Increment(ref _misses);
var value = await factory(cancellationToken).ConfigureAwait(false);
_cache[key] = new CacheEntry(value!, _timeProvider.GetUtcNow().Add(ttl));
Evict();
return value;
}
finally
{
gate.Semaphore.Release();
}
}
finally
{
ReturnGate(key, gate);
}
}
/// <summary>
/// Invalidates a single cached entry.
/// </summary>
public void Remove(string key) => _cache.TryRemove(key, out _);
/// <summary>
/// Invalidates all cached entries and releases the per-key locks that are idle.
/// </summary>
public void Clear()
{
_cache.Clear();
foreach (var pair in _locks)
{
TryDropGate(pair.Key, pair.Value);
}
}
private bool TryGet<T>(string key, out T value)
{
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow())
{
value = (T)entry.Value;
return true;
}
value = default!;
return false;
}
private void Evict()
{
var now = _timeProvider.GetUtcNow();
foreach (var entry in _cache)
{
if (entry.Value.ExpiresAt <= now)
{
_cache.TryRemove(entry.Key, out _);
}
}
while (_cache.Count > _capacity)
{
var oldest = _cache.OrderBy(e => e.Value.ExpiresAt).Select(e => e.Key).FirstOrDefault();
if (oldest is null || !_cache.TryRemove(oldest, out _))
{
break;
}
}
}
// A gate lives only as long as someone is using it, so the lock dictionary stays
// bounded by the number of in-flight calls instead of the number of keys ever seen.
private Gate RentGate(string key)
{
while (true)
{
var gate = _locks.GetOrAdd(key, _ => new Gate());
lock (gate)
{
if (!gate.Dropped)
{
gate.Users++;
return gate;
}
}
}
}
private void ReturnGate(string key, Gate gate)
{
lock (gate)
{
gate.Users--;
DropIfIdle(key, gate);
}
}
private void TryDropGate(string key, Gate gate)
{
lock (gate)
{
DropIfIdle(key, gate);
}
}
private void DropIfIdle(string key, Gate gate)
{
if (gate.Users > 0 || gate.Dropped)
{
return;
}
gate.Dropped = true;
_locks.TryRemove(new KeyValuePair<string, Gate>(key, gate));
gate.Semaphore.Dispose();
}
private sealed record CacheEntry(object Value, DateTimeOffset ExpiresAt);
private sealed class Gate
{
public SemaphoreSlim Semaphore { get; } = new(1, 1);
public int Users { get; set; }
public bool Dropped { get; set; }
}
}