using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Controllers; using MediaBrowser.Controller.MediaEncoding; using Xunit; namespace Jellyfin.Api.Tests.Controllers { /// /// Tests for HA recovery scenarios that will be wired into /// in Phase 5.2. These tests verify the contract that /// the controller will rely on for missing-local-job recovery, claim racing, and cleanup guarding. /// public class DynamicHlsHaTakeoverTests { private static TranscodeSession CreateSession(string id, string pod, DateTime leaseExpiry) => new TranscodeSession { PlaySessionId = id, OwnerPod = pod, LeaseExpiresUtc = leaseExpiry, ManifestPath = $"/transcode/{id}/manifest.m3u8", SegmentPathPrefix = $"/transcode/{id}/segment", MediaSourceId = "media-source-1", LastCompletedSegmentIndex = 3, LastDurablePlaybackOffset = 18_000_000L, }; /// /// Missing-local-job + durable-manifest-present: the store returns the session so /// the controller can serve the existing manifest instead of returning an error. /// [Fact] [Trait("Category", "UnitTest")] public async Task DurableManifestPresent_WithLiveSession_StoreReturnsSession() { var store = new HaTestSessionStore(); var session = CreateSession("ha-session-1", "pod-a", DateTime.UtcNow.AddMinutes(5)); await store.SetAsync(session); // Simulate controller recovery: look up the session in the durable store. var recovered = await store.TryGetAsync("ha-session-1"); Assert.NotNull(recovered); Assert.Equal("/transcode/ha-session-1/manifest.m3u8", recovered.ManifestPath); } /// /// Claim-race between two concurrent requesters: only one wins /// . /// The other receives false, indicating it should redirect (302) or wait. /// [Fact] [Trait("Category", "UnitTest")] public async Task ClaimRace_TwoConcurrentRequesters_OnlyOneWinsTakeover() { var store = new HaTestSessionStore(); // Original pod crashed – lease is expired. var session = CreateSession("ha-session-2", "pod-a", DateTime.UtcNow.AddMilliseconds(-1)); await store.SetAsync(session); // Two pods simultaneously attempt to claim the orphaned session. var task1 = store.TryTakeoverAsync("ha-session-2", "pod-b"); var task2 = store.TryTakeoverAsync("ha-session-2", "pod-c"); var results = await Task.WhenAll(task1, task2); // Exactly one pod must win. var wins = Array.FindAll(results, r => r); Assert.Single(wins); } /// /// Stale-manifest cleanup guard: a lease that has expired beyond the recovery window /// causes the store to return null, signalling that cleanup may proceed safely. /// [Fact] [Trait("Category", "UnitTest")] public async Task StaleManifestCleanupGuard_ExpiredBeyondRecoveryWindow_StoreReturnsNull() { var store = new HaTestSessionStore(); // Lease expired hours ago – well beyond any recovery window. var session = CreateSession("ha-session-3", "pod-a", DateTime.UtcNow.AddHours(-2)); await store.SetAsync(session); // Controller or cleanup task checks the store before deleting files. var liveSession = await store.TryGetAsync("ha-session-3"); // Store returns null → cleanup may proceed without risking data loss. Assert.Null(liveSession); } /// /// Segment-length selection: when the play-session has an active entry in the store /// (HA mode is active), the recovery segment length should be preferred over the normal one. /// [Fact] [Trait("Category", "UnitTest")] public async Task SegmentLength_UsesRecoveryValue_WhenHaModeIsActive() { const int normalSegmentLength = 6; const int recoverySegmentLength = 2; var store = new HaTestSessionStore(); var session = CreateSession("ha-session-4", "pod-a", DateTime.UtcNow.AddMinutes(5)); await store.SetAsync(session); // Simulate the controller's HA-mode check: if the session is in the store, HA mode is active. var existingSession = await store.TryGetAsync("ha-session-4"); var isHaMode = existingSession is not null; var effectiveSegmentLength = isHaMode ? recoverySegmentLength : normalSegmentLength; Assert.True(isHaMode, "Session should be found in the store, activating HA mode."); Assert.Equal(recoverySegmentLength, effectiveSegmentLength); } /// /// Segment-length selection: when no entry exists in the store for the play-session /// (HA mode inactive), the normal segment length should be used. /// [Fact] [Trait("Category", "UnitTest")] public async Task SegmentLength_UsesNormalValue_WhenHaModeIsInactive() { const int normalSegmentLength = 6; const int recoverySegmentLength = 2; var store = new HaTestSessionStore(); // No session registered – HA mode is inactive. var existingSession = await store.TryGetAsync("nonexistent-session"); var isHaMode = existingSession is not null; var effectiveSegmentLength = isHaMode ? recoverySegmentLength : normalSegmentLength; Assert.False(isHaMode, "No session in the store means HA mode should be inactive."); Assert.Equal(normalSegmentLength, effectiveSegmentLength); } /// /// Minimal in-memory used within this test class /// to avoid a cross-project reference to Jellyfin.MediaEncoding.Tests. /// private sealed class HaTestSessionStore : ITranscodeSessionStore { private static readonly TimeSpan LeaseDuration = TimeSpan.FromSeconds(30); private readonly Dictionary _sessions = new(StringComparer.OrdinalIgnoreCase); private readonly Lock _lock = new(); public Task TryGetAsync(string playSessionId, CancellationToken cancellationToken = default) { lock (_lock) { if (_sessions.TryGetValue(playSessionId, out var s) && s.LeaseExpiresUtc > DateTime.UtcNow) { return Task.FromResult(Clone(s)); } return Task.FromResult(null); } } public Task TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default) { lock (_lock) { if (!_sessions.TryGetValue(playSessionId, out var s)) { return Task.FromResult(false); } if (s.LeaseExpiresUtc > DateTime.UtcNow) { return Task.FromResult(false); } s.OwnerPod = claimingPod; s.LeaseExpiresUtc = DateTime.UtcNow.Add(LeaseDuration); return Task.FromResult(true); } } public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default) { lock (_lock) { _sessions[session.PlaySessionId] = session; } return Task.CompletedTask; } public Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default) { lock (_lock) { if (_sessions.TryGetValue(playSessionId, out var s)) { s.LeaseExpiresUtc = DateTime.UtcNow.Add(LeaseDuration); } } return Task.CompletedTask; } public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default) { lock (_lock) { _sessions.Remove(playSessionId); } return Task.CompletedTask; } public Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default) { lock (_lock) { var sessions = _sessions.Values .Where(s => s.LeaseExpiresUtc > DateTime.UtcNow) .Select(Clone) .ToList(); return Task.FromResult>(sessions); } } public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) => Task.CompletedTask; private static TranscodeSession Clone(TranscodeSession source) => new TranscodeSession { PlaySessionId = source.PlaySessionId, OwnerPod = source.OwnerPod, LeaseExpiresUtc = source.LeaseExpiresUtc, ManifestPath = source.ManifestPath, SegmentPathPrefix = source.SegmentPathPrefix, MediaSourceId = source.MediaSourceId, LastCompletedSegmentIndex = source.LastCompletedSegmentIndex, LastDurablePlaybackOffset = source.LastDurablePlaybackOffset, }; } } }