Remove obsolete rule formats, batch SQLite work, preserve artwork source URLs, and coordinate refresh workers with maintenance. Add regression coverage and document backup format changes.
152 lines
7.0 KiB
C#
152 lines
7.0 KiB
C#
using Jellyfin.Plugin.Multilang.Services;
|
|
using Jellyfin.Plugin.Multilang.Services.Refresh;
|
|
|
|
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()
|
|
{
|
|
using var coordinator = new RefreshCoordinator(concurrency: 1);
|
|
var activeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var releaseActive = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var order = new List<string>();
|
|
|
|
var active = coordinator.EnqueueAsync("active", RefreshSourceTier.Background, RefreshWorkClass.Series, RefreshJobType.Full, async (_, _) =>
|
|
{
|
|
activeStarted.SetResult();
|
|
await releaseActive.Task;
|
|
return true;
|
|
}, CancellationToken.None);
|
|
await activeStarted.Task;
|
|
|
|
var series = coordinator.EnqueueAsync("series", RefreshSourceTier.Background, RefreshWorkClass.Series, RefreshJobType.Full, (_, _) =>
|
|
{
|
|
order.Add("series");
|
|
return Task.FromResult(true);
|
|
}, CancellationToken.None);
|
|
var movie = coordinator.EnqueueAsync("movie", RefreshSourceTier.Background, RefreshWorkClass.Movies, RefreshJobType.Full, (_, _) =>
|
|
{
|
|
order.Add("movie");
|
|
return Task.FromResult(true);
|
|
}, CancellationToken.None);
|
|
var onTheFlyCollection = coordinator.EnqueueAsync("collection", RefreshSourceTier.OnTheFly, RefreshWorkClass.Collections, RefreshJobType.Full, (_, _) =>
|
|
{
|
|
order.Add("collection");
|
|
return Task.FromResult(true);
|
|
}, CancellationToken.None);
|
|
|
|
releaseActive.SetResult();
|
|
await Task.WhenAll(active, series, movie, onTheFlyCollection);
|
|
|
|
Assert.Equal(["collection", "movie", "series"], order);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DuplicateQueuedItemUsesHighestJobAndTierWithoutInvalidatingUpstreamData()
|
|
{
|
|
var cache = new ItemsProxyCache();
|
|
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>();
|
|
|
|
var active = coordinator.EnqueueAsync("active", RefreshSourceTier.Background, RefreshWorkClass.Series, RefreshJobType.Full, async (_, _) =>
|
|
{
|
|
activeStarted.SetResult();
|
|
await releaseActive.Task;
|
|
return true;
|
|
}, CancellationToken.None);
|
|
await activeStarted.Task;
|
|
|
|
var missing = coordinator.EnqueueAsync("target", RefreshSourceTier.Background, RefreshWorkClass.Series, RefreshJobType.Missing, (job, _) =>
|
|
{
|
|
runs.Add(job);
|
|
return Task.FromResult(true);
|
|
}, CancellationToken.None);
|
|
var full = coordinator.EnqueueAsync("target", RefreshSourceTier.Manual, RefreshWorkClass.Series, RefreshJobType.Full, (job, _) =>
|
|
{
|
|
runs.Add(job);
|
|
return Task.FromResult(true);
|
|
}, CancellationToken.None);
|
|
|
|
Assert.Same(missing, full);
|
|
releaseActive.SetResult();
|
|
await Task.WhenAll(active, full);
|
|
|
|
Assert.Equal([RefreshJobType.Full], runs);
|
|
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);
|
|
}
|
|
}
|