diff --git a/MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs b/MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs
new file mode 100644
index 000000000..9ab00f70a
--- /dev/null
+++ b/MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs
@@ -0,0 +1,62 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace MediaBrowser.Controller.MediaEncoding;
+
+///
+/// Provides a durable store for HLS transcoding session state, enabling
+/// HA recovery and lease-based ownership between pods.
+///
+public interface ITranscodeSessionStore
+{
+ ///
+ /// Attempts to retrieve a transcoding session by its play session identifier.
+ ///
+ /// The play session identifier.
+ /// A cancellation token.
+ ///
+ /// The if it exists and its lease has not expired;
+ /// otherwise null.
+ ///
+ Task TryGetAsync(string playSessionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Attempts to take over ownership of an existing session by claiming the lease for
+ /// . Takeover succeeds only when the session exists and
+ /// its current lease has already expired.
+ ///
+ /// The play session identifier.
+ /// The name of the pod attempting to claim ownership.
+ /// A cancellation token.
+ ///
+ /// true if the takeover succeeded (the claiming pod now holds the lease);
+ /// false if the session does not exist, its lease is still valid, or another
+ /// concurrent caller already claimed it.
+ ///
+ Task TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default);
+
+ ///
+ /// Persists a new or updated transcoding session.
+ ///
+ /// The session to store.
+ /// A cancellation token.
+ /// A representing the asynchronous operation.
+ Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default);
+
+ ///
+ /// Renews the lease for an existing session, extending its
+ /// by the store's configured lease duration.
+ ///
+ /// The play session identifier.
+ /// A cancellation token.
+ /// A representing the asynchronous operation.
+ Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Removes a transcoding session from the store.
+ ///
+ /// The play session identifier.
+ /// A cancellation token.
+ /// A representing the asynchronous operation.
+ Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default);
+}
diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodeSession.cs b/MediaBrowser.Controller/MediaEncoding/TranscodeSession.cs
new file mode 100644
index 000000000..690f36e7c
--- /dev/null
+++ b/MediaBrowser.Controller/MediaEncoding/TranscodeSession.cs
@@ -0,0 +1,49 @@
+using System;
+
+namespace MediaBrowser.Controller.MediaEncoding;
+
+///
+/// Represents a durable record of an HLS transcoding session for HA pod recovery.
+///
+public sealed class TranscodeSession
+{
+ ///
+ /// Gets or sets the unique play session identifier.
+ ///
+ public string PlaySessionId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the name of the pod that currently owns this session's lease.
+ ///
+ public string OwnerPod { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the UTC time at which the owning pod's lease expires.
+ ///
+ public DateTime LeaseExpiresUtc { get; set; }
+
+ ///
+ /// Gets or sets the absolute path to the HLS manifest (.m3u8) file on shared storage.
+ ///
+ public string ManifestPath { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the path prefix for transcoded segment files on shared storage.
+ ///
+ public string SegmentPathPrefix { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the media source identifier associated with this session.
+ ///
+ public string MediaSourceId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the zero-based index of the last segment that was fully written to durable storage.
+ ///
+ public int LastCompletedSegmentIndex { get; set; }
+
+ ///
+ /// Gets or sets the last durable playback offset in ticks, used to resume playback after failover.
+ ///
+ public long LastDurablePlaybackOffset { get; set; }
+}
diff --git a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsHaTakeoverTests.cs b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsHaTakeoverTests.cs
new file mode 100644
index 000000000..23b231e11
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsHaTakeoverTests.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Collections.Generic;
+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);
+ }
+
+ ///
+ /// 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;
+ }
+
+ 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,
+ };
+ }
+ }
+}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs b/tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs
new file mode 100644
index 000000000..82772ca67
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.MediaEncoding;
+
+namespace Jellyfin.MediaEncoding.Tests.Fakes;
+
+///
+/// Thread-safe, in-memory implementation of for use in unit tests.
+///
+public sealed class InMemoryTranscodeSessionStore : ITranscodeSessionStore
+{
+ ///
+ /// The duration added to when a lease is renewed or first claimed.
+ ///
+ public static readonly TimeSpan DefaultLeaseDuration = 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 session) && session.LeaseExpiresUtc > DateTime.UtcNow)
+ {
+ return Task.FromResult(Clone(session));
+ }
+
+ return Task.FromResult(null);
+ }
+ }
+
+ ///
+ public Task TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ if (!_sessions.TryGetValue(playSessionId, out var session))
+ {
+ return Task.FromResult(false);
+ }
+
+ if (session.LeaseExpiresUtc > DateTime.UtcNow)
+ {
+ // Another pod's lease is still valid – takeover not permitted.
+ return Task.FromResult(false);
+ }
+
+ // Lease has expired – claim it atomically.
+ session.OwnerPod = claimingPod;
+ session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration);
+ 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 session))
+ {
+ session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ _sessions.Remove(playSessionId);
+ }
+
+ return 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,
+ };
+}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Transcoding/TranscodeManagerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Transcoding/TranscodeManagerTests.cs
new file mode 100644
index 000000000..959256c9b
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Transcoding/TranscodeManagerTests.cs
@@ -0,0 +1,167 @@
+using System;
+using System.Threading.Tasks;
+using Jellyfin.MediaEncoding.Tests.Fakes;
+using MediaBrowser.Controller.MediaEncoding;
+using Xunit;
+
+namespace Jellyfin.MediaEncoding.Tests.Transcoding;
+
+///
+/// Unit tests for the HA session-store contract: lease expiry, double-claim prevention,
+/// heartbeat renewal, and stale-session cleanup.
+/// All tests exercise which implements the
+/// interface that will be backed by Redis in Phase 5.2.
+///
+public class TranscodeManagerTests
+{
+ private static TranscodeSession CreateSession(
+ string id,
+ string pod,
+ DateTime leaseExpiry,
+ int lastSegmentIndex = 0,
+ long lastOffset = 0L)
+ => new TranscodeSession
+ {
+ PlaySessionId = id,
+ OwnerPod = pod,
+ LeaseExpiresUtc = leaseExpiry,
+ ManifestPath = $"/transcode/{id}/manifest.m3u8",
+ SegmentPathPrefix = $"/transcode/{id}/segment",
+ MediaSourceId = $"media-source-{id}",
+ LastCompletedSegmentIndex = lastSegmentIndex,
+ LastDurablePlaybackOffset = lastOffset,
+ };
+
+ ///
+ /// Lease expiry: returns null
+ /// once has passed.
+ ///
+ [Fact]
+ [Trait("Category", "UnitTest")]
+ public async Task TryGetAsync_AfterLeaseExpires_ReturnsNull()
+ {
+ var store = new InMemoryTranscodeSessionStore();
+ var session = CreateSession("session-expired", "pod-a", DateTime.UtcNow.AddMilliseconds(-1));
+ await store.SetAsync(session);
+
+ var result = await store.TryGetAsync("session-expired");
+
+ Assert.Null(result);
+ }
+
+ ///
+ /// A session whose lease has not yet expired is returned correctly.
+ ///
+ [Fact]
+ [Trait("Category", "UnitTest")]
+ public async Task TryGetAsync_WithinLease_ReturnsSession()
+ {
+ var store = new InMemoryTranscodeSessionStore();
+ var session = CreateSession("session-live", "pod-a", DateTime.UtcNow.AddMinutes(5), lastSegmentIndex: 3, lastOffset: 18_000_000L);
+ await store.SetAsync(session);
+
+ var result = await store.TryGetAsync("session-live");
+
+ Assert.NotNull(result);
+ Assert.Equal("session-live", result.PlaySessionId);
+ Assert.Equal("pod-a", result.OwnerPod);
+ Assert.Equal(3, result.LastCompletedSegmentIndex);
+ Assert.Equal(18_000_000L, result.LastDurablePlaybackOffset);
+ }
+
+ ///
+ /// Double-claim prevention: returns
+ /// false while the first pod's lease is still valid.
+ ///
+ [Fact]
+ [Trait("Category", "UnitTest")]
+ public async Task TryTakeoverAsync_WhileLeaseValid_ReturnsFalse()
+ {
+ var store = new InMemoryTranscodeSessionStore();
+ var session = CreateSession("session-valid", "pod-a", DateTime.UtcNow.AddMinutes(5));
+ await store.SetAsync(session);
+
+ var firstAttempt = await store.TryTakeoverAsync("session-valid", "pod-b");
+ var secondAttempt = await store.TryTakeoverAsync("session-valid", "pod-c");
+
+ Assert.False(firstAttempt);
+ Assert.False(secondAttempt);
+ }
+
+ ///
+ /// After a lease expires, the first concurrent caller that invokes
+ /// wins; the second caller returns false.
+ ///
+ [Fact]
+ [Trait("Category", "UnitTest")]
+ public async Task TryTakeoverAsync_AfterLeaseExpires_OnlyFirstClaimerSucceeds()
+ {
+ var store = new InMemoryTranscodeSessionStore();
+ var session = CreateSession("session-stale", "pod-a", DateTime.UtcNow.AddMilliseconds(-1));
+ await store.SetAsync(session);
+
+ // First pod wins; its takeover renews the lease atomically.
+ var firstTakeover = await store.TryTakeoverAsync("session-stale", "pod-b");
+
+ // Second pod is too late – pod-b already holds a fresh lease.
+ var secondTakeover = await store.TryTakeoverAsync("session-stale", "pod-c");
+
+ Assert.True(firstTakeover);
+ Assert.False(secondTakeover);
+ }
+
+ ///
+ /// Heartbeat renewal: extends
+ /// beyond its original value.
+ ///
+ [Fact]
+ [Trait("Category", "UnitTest")]
+ public async Task RenewLeaseAsync_ExtendsLeaseExpiry()
+ {
+ var store = new InMemoryTranscodeSessionStore();
+ var originalExpiry = DateTime.UtcNow.AddSeconds(5);
+ var session = CreateSession("session-renew", "pod-a", originalExpiry, lastSegmentIndex: 2, lastOffset: 10_000_000L);
+ await store.SetAsync(session);
+
+ await store.RenewLeaseAsync("session-renew");
+
+ var renewed = await store.TryGetAsync("session-renew");
+ Assert.NotNull(renewed);
+ Assert.True(
+ renewed.LeaseExpiresUtc > originalExpiry,
+ "Renewed lease expiry should be later than the original expiry.");
+ }
+
+ ///
+ /// Stale-session cleanup: an expired session can be deleted without error, and a
+ /// subsequent returns null.
+ ///
+ [Fact]
+ [Trait("Category", "UnitTest")]
+ public async Task DeleteAsync_ExpiredSession_CompletesWithoutError()
+ {
+ var store = new InMemoryTranscodeSessionStore();
+ var session = CreateSession("session-delete", "pod-a", DateTime.UtcNow.AddMilliseconds(-1));
+ await store.SetAsync(session);
+
+ var ex = await Record.ExceptionAsync(() => store.DeleteAsync("session-delete"));
+ Assert.Null(ex);
+
+ var result = await store.TryGetAsync("session-delete");
+ Assert.Null(result);
+ }
+
+ ///
+ /// Deleting a session that was never stored must complete without error.
+ ///
+ [Fact]
+ [Trait("Category", "UnitTest")]
+ public async Task DeleteAsync_NonExistentSession_CompletesWithoutError()
+ {
+ var store = new InMemoryTranscodeSessionStore();
+
+ var ex = await Record.ExceptionAsync(() => store.DeleteAsync("nonexistent-session"));
+
+ Assert.Null(ex);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/ScheduledTasks/DeleteTranscodeFileTaskTests.cs b/tests/Jellyfin.Server.Implementations.Tests/ScheduledTasks/DeleteTranscodeFileTaskTests.cs
new file mode 100644
index 000000000..30d6b7470
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/ScheduledTasks/DeleteTranscodeFileTaskTests.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.MediaEncoding;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks;
+
+///
+/// Tests for lease-aware cleanup behavior expected of DeleteTranscodeFileTask once
+/// it is made HA-aware in Phase 5.2.
+///
+/// The current DeleteTranscodeFileTask implementation uses file-age only and does not
+/// check , which creates a data-loss risk on shared NFS
+/// storage. These tests document the correct contract by exercising the store directly.
+///
+///
+public class DeleteTranscodeFileTaskTests
+{
+ 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-{id}",
+ LastCompletedSegmentIndex = 2,
+ LastDurablePlaybackOffset = 12_000_000L,
+ };
+
+ ///
+ /// A directory that belongs to a session with a live lease must NOT be deleted.
+ /// The store returns non-null, signalling to the cleanup task that the session is active.
+ ///
+ [Fact]
+ [Trait("Category", "UnitTest")]
+ public async Task LiveLease_StoreReturnsSession_DirectoryShouldNotBeDeleted()
+ {
+ var store = new CleanupTestSessionStore();
+ var session = CreateSession("cleanup-session-1", "pod-a", DateTime.UtcNow.AddMinutes(5));
+ await store.SetAsync(session);
+
+ // The cleanup task should query the store before deleting.
+ var liveSession = await store.TryGetAsync("cleanup-session-1");
+
+ // Non-null result → lease is active → directory must be retained.
+ Assert.NotNull(liveSession);
+ Assert.Equal("pod-a", liveSession.OwnerPod);
+ Assert.True(liveSession.LeaseExpiresUtc > DateTime.UtcNow);
+ }
+
+ ///
+ /// A directory whose session lease has expired beyond the recovery window MAY be deleted.
+ /// The store returns null, signalling to the cleanup task that deletion is safe.
+ ///
+ [Fact]
+ [Trait("Category", "UnitTest")]
+ public async Task ExpiredBeyondRecoveryWindow_StoreReturnsNull_DirectoryMayBeDeleted()
+ {
+ var store = new CleanupTestSessionStore();
+
+ // Lease expired two hours ago – beyond any reasonable recovery window.
+ var session = CreateSession("cleanup-session-2", "pod-a", DateTime.UtcNow.AddHours(-2));
+ await store.SetAsync(session);
+
+ var liveSession = await store.TryGetAsync("cleanup-session-2");
+
+ // Null result → lease is expired → cleanup task may delete the directory.
+ Assert.Null(liveSession);
+ }
+
+ ///
+ /// When no session record exists in the store for a given directory, the cleanup task
+ /// should treat the directory as deletable (store returns null).
+ ///
+ [Fact]
+ [Trait("Category", "UnitTest")]
+ public async Task NoSessionRecord_StoreReturnsNull_DirectoryMayBeDeleted()
+ {
+ var store = new CleanupTestSessionStore();
+
+ var liveSession = await store.TryGetAsync("unknown-session");
+
+ Assert.Null(liveSession);
+ }
+
+ ///
+ /// Minimal in-memory used within this test class
+ /// to avoid a cross-project reference to Jellyfin.MediaEncoding.Tests.
+ ///
+ private sealed class CleanupTestSessionStore : 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;
+ }
+
+ 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,
+ };
+ }
+}