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 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(() => 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(); 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(); 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); } }