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, + }; + } +}