Release 0.3.4: Jellyfin 12 stable support and audit fixes

Harden proxy permissions and cache behavior, streamline metadata fetching, remove obsolete rules, and validate the stable SDK with expanded regression coverage. Document backup format changes.
This commit is contained in:
ajp_anton
2026-09-09 01:18:43 +00:00
parent 8f7462fd3c
commit 096c6be8c7
50 changed files with 2126 additions and 1784 deletions
@@ -5,10 +5,76 @@ namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class RefreshCoordinatorTests
{
[Fact]
public async Task IndependentItemsOverlapWithinWorkerLimitAndDuplicateWorkIsShared()
{
using var coordinator = new RefreshCoordinator(concurrency: 2);
var twoStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var count = 0;
Task<bool> Queue(string id) => coordinator.EnqueueAsync(id, RefreshSourceTier.Manual, RefreshWorkClass.Movies,
RefreshJobType.Full, async (_, _) =>
{
if (Interlocked.Increment(ref count) == 2) twoStarted.SetResult();
await release.Task;
return true;
}, CancellationToken.None);
var first = Queue("first");
var second = Queue("second");
await twoStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Same(first, Queue("first"));
var third = Queue("third");
Assert.Equal(2, count);
Assert.Equal(2, coordinator.GetDiagnostics().ActiveItems.Length);
release.SetResult();
await Task.WhenAll(first, second, third).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(3, count);
}
[Fact]
public async Task ActiveMissingRefreshRunsFullUpgradeBeforeCompleting()
{
using var coordinator = new RefreshCoordinator(concurrency: 1);
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var fullRan = false;
var first = coordinator.EnqueueAsync("item", RefreshSourceTier.Background, RefreshWorkClass.Movies, RefreshJobType.Missing, async (_, _) =>
{
started.SetResult();
await release.Task;
return false;
}, CancellationToken.None);
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
var full = coordinator.EnqueueAsync("item", RefreshSourceTier.Manual, RefreshWorkClass.Movies, RefreshJobType.Full, (_, _) =>
{
fullRan = true;
return Task.FromResult(true);
}, CancellationToken.None);
release.SetResult();
Assert.True(await full.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.True(fullRan);
Assert.True(await first);
}
[Fact]
public async Task CanceledWaiterDoesNotCancelAnotherWaiter()
{
using var coordinator = new RefreshCoordinator(concurrency: 1);
using var cancel = new CancellationTokenSource();
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var canceled = coordinator.EnqueueAsync("item", RefreshSourceTier.Background, RefreshWorkClass.Movies, RefreshJobType.Full,
async (_, _) => { await release.Task; return true; }, cancel.Token);
var survivor = coordinator.EnqueueAsync("item", RefreshSourceTier.Background, RefreshWorkClass.Movies, RefreshJobType.Full,
(_, _) => Task.FromResult(false), CancellationToken.None);
cancel.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => canceled);
release.SetResult();
Assert.True(await survivor.WaitAsync(TimeSpan.FromSeconds(5)));
}
[Fact]
public async Task QueuedItemsUseSourceTierThenWorkClassPriority()
{
var coordinator = new RefreshCoordinator(new ItemsProxyCache());
using var coordinator = new RefreshCoordinator(concurrency: 1);
var activeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseActive = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var order = new List<string>();
@@ -44,11 +110,11 @@ public sealed class RefreshCoordinatorTests
}
[Fact]
public async Task DuplicateQueuedItemUsesTheHighestJobAndTierAndInvalidatesCache()
public async Task DuplicateQueuedItemUsesHighestJobAndTierWithoutInvalidatingUpstreamData()
{
var cache = new ItemsProxyCache();
Assert.True(cache.Store("target", "user", "/Items", ["target"], "body", "application/json", 1, 1024, TimeSpan.FromMinutes(1)));
var coordinator = new RefreshCoordinator(cache);
Assert.True(cache.Store("target", "user", "/Items", 1, "body", "application/json", 1, 1024, TimeSpan.FromMinutes(1)));
using var coordinator = new RefreshCoordinator(concurrency: 1);
var activeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseActive = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var runs = new List<RefreshJobType>();
@@ -77,7 +143,7 @@ public sealed class RefreshCoordinatorTests
await Task.WhenAll(active, full);
Assert.Equal([RefreshJobType.Full], runs);
Assert.False(cache.TryGet("target", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.TryGet("target", TimeSpan.FromMinutes(1), out _));
var recent = coordinator.GetDiagnostics().Recent.Single(item => item.ItemId == "target");
Assert.Equal("Manual", recent.SourceTier);
Assert.Equal("Full", recent.JobType);