diff --git a/Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs b/Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs index 694315b8d..6ac5c0120 100644 --- a/Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs +++ b/Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs @@ -18,6 +18,7 @@ namespace Emby.Server.Implementations.MediaEncoding; public sealed class RedisTranscodeSessionStore : ITranscodeSessionStore { private const string KeyPrefix = "jellyfin:transcode:"; + private const string LiveStreamKeyPrefix = "jellyfin:livestream:"; /// /// Lua script for atomic takeover: reads the stored session, checks whether the lease has @@ -145,6 +146,9 @@ return 1"; private static string GetKey(string playSessionId) => KeyPrefix + playSessionId; + private static string GetLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId) + => LiveStreamKeyPrefix + liveStreamId + ":" + sessionIdOrPlaySessionId; + /// public async Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default) { @@ -194,4 +198,73 @@ return 1"; return sessions; } + + /// + public async Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default) + { + var key = GetLiveStreamKey(session.LiveStreamId, session.SessionId); + var json = JsonSerializer.Serialize(session); + // Live stream records use the same lease duration as transcode sessions. + var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000; + await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false); + + // Also index by play session id so the caller can look up by either key. + if (!string.IsNullOrEmpty(session.PlaySessionId)) + { + var playKey = GetLiveStreamKey(session.LiveStreamId, session.PlaySessionId); + await _db.StringSetAsync(playKey, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false); + } + + _logger.LogDebug( + "Set live stream session {LiveStreamId}/{SessionId} in Redis.", + session.LiveStreamId, + session.SessionId); + } + + /// + public async Task TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + { + var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId); + var raw = await _db.StringGetAsync(key).ConfigureAwait(false); + if (!raw.HasValue) + { + return null; + } + + return JsonSerializer.Deserialize(raw.ToString()); + } + + /// + public async Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + { + var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId); + var raw = await _db.StringGetAsync(key).ConfigureAwait(false); + if (raw.HasValue) + { + var session = JsonSerializer.Deserialize(raw.ToString()); + if (session is not null) + { + // Remove both the session-id key and the play-session-id key if present. + var keysToDelete = new System.Collections.Generic.List + { + GetLiveStreamKey(liveStreamId, session.SessionId) + }; + + if (!string.IsNullOrEmpty(session.PlaySessionId)) + { + keysToDelete.Add(GetLiveStreamKey(liveStreamId, session.PlaySessionId)); + } + + await _db.KeyDeleteAsync(keysToDelete.ToArray()).ConfigureAwait(false); + _logger.LogDebug( + "Deleted live stream session {LiveStreamId}/{SessionId} from Redis.", + liveStreamId, + session.SessionId); + return; + } + } + + // Fallback: delete just the key that was supplied. + await _db.KeyDeleteAsync(key).ConfigureAwait(false); + } } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 2eeeecfec..01b1bb7db 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -28,6 +28,7 @@ using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Authentication; using MediaBrowser.Controller.Events.Session; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; @@ -60,6 +61,7 @@ namespace Emby.Server.Implementations.Session private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerApplicationHost _appHost; private readonly IDeviceManager _deviceManager; + private readonly ITranscodeSessionStore _transcodeSessionStore; private readonly CancellationTokenRegistration _shutdownCallback; private readonly ConcurrentDictionary _activeConnections = new(StringComparer.OrdinalIgnoreCase); @@ -89,6 +91,7 @@ namespace Emby.Server.Implementations.Session /// Instance of interface. /// Instance of interface. /// Instance of interface. + /// Instance of interface. public SessionManager( ILogger logger, IEventManager eventManager, @@ -102,7 +105,8 @@ namespace Emby.Server.Implementations.Session IServerApplicationHost appHost, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager, - IHostApplicationLifetime hostApplicationLifetime) + IHostApplicationLifetime hostApplicationLifetime, + ITranscodeSessionStore transcodeSessionStore) { _logger = logger; _eventManager = eventManager; @@ -116,6 +120,7 @@ namespace Emby.Server.Implementations.Session _appHost = appHost; _deviceManager = deviceManager; _mediaSourceManager = mediaSourceManager; + _transcodeSessionStore = transcodeSessionStore; _shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping); _deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated; @@ -343,9 +348,38 @@ namespace Emby.Server.Implementations.Session _activeLiveStreamSessions.TryRemove(liveStreamId, out _); } } + else + { + // In-memory state is absent — this pod may have taken over from a crashed pod. + // Check the durable store to determine whether the live stream record exists. + LiveStreamSession durableRecord = null; + try + { + durableRecord = await _transcodeSessionStore.TryGetLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to query live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId); + } + if (durableRecord is not null) + { + liveStreamNeedsToBeClosed = true; + } + } + + // Remove the durable record regardless of which code path set liveStreamNeedsToBeClosed. if (liveStreamNeedsToBeClosed) { + try + { + await _transcodeSessionStore.DeleteLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId); + } + try { await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); @@ -776,7 +810,7 @@ namespace Emby.Server.Implementations.Session if (!string.IsNullOrEmpty(info.LiveStreamId)) { - UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId); + await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false); } var eventArgs = new PlaybackStartEventArgs @@ -836,7 +870,7 @@ namespace Emby.Server.Implementations.Session return OnPlaybackProgress(info, false); } - private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId) + private async Task UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId) { var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(liveStreamId, _ => new ConcurrentDictionary()); @@ -860,6 +894,26 @@ namespace Emby.Server.Implementations.Session activeSessionMappings[sessionId] = string.Empty; } } + + // Persist to the durable store so a takeover pod can discover open live streams. + var ownerPod = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName; + var liveStreamSession = new LiveStreamSession + { + LiveStreamId = liveStreamId, + SessionId = sessionId, + PlaySessionId = playSessionId ?? string.Empty, + OwnerPod = ownerPod, + OpenedAtUtc = DateTime.UtcNow, + }; + + try + { + await _transcodeSessionStore.SetLiveStreamAsync(liveStreamSession).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to persist live stream session {LiveStreamId}/{SessionId} to durable store.", liveStreamId, sessionId); + } } /// @@ -904,7 +958,7 @@ namespace Emby.Server.Implementations.Session if (!string.IsNullOrEmpty(info.LiveStreamId)) { - UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId); + await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false); } var eventArgs = new PlaybackProgressEventArgs diff --git a/MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs b/MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs index 9ae802731..e72d327cd 100644 --- a/MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs +++ b/MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs @@ -70,4 +70,35 @@ public interface ITranscodeSessionStore /// Returns an empty enumerable if no sessions are active or if the store cannot be reached. /// Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default); + + /// + /// Persists a live stream session record so that takeover pods can identify and close + /// streams that were opened on a pod that has since crashed or been evicted. + /// + /// The live stream session to store. + /// A cancellation token. + /// A representing the asynchronous operation. + Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default); + + /// + /// Attempts to retrieve a live stream session by its live stream identifier and the + /// session or play-session identifier that owns it. + /// + /// The live stream identifier. + /// The session identifier or play-session identifier. + /// A cancellation token. + /// + /// The if it exists; otherwise null. + /// + Task TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default); + + /// + /// Removes the live stream session record for the given live stream and session identifier. + /// This is called when the stream is closed, either by the owning pod or a takeover pod. + /// + /// The live stream identifier. + /// The session identifier or play-session identifier. + /// A cancellation token. + /// A representing the asynchronous operation. + Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default); } diff --git a/MediaBrowser.Controller/MediaEncoding/LiveStreamSession.cs b/MediaBrowser.Controller/MediaEncoding/LiveStreamSession.cs new file mode 100644 index 000000000..6549d4adb --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/LiveStreamSession.cs @@ -0,0 +1,36 @@ +using System; + +namespace MediaBrowser.Controller.MediaEncoding; + +/// +/// Represents a durable record of an open live stream session, enabling HA pod recovery +/// when the owning pod crashes or is evicted. +/// +public sealed class LiveStreamSession +{ + /// + /// Gets or sets the live stream identifier (e.g. a TV tuner channel token). + /// + public string LiveStreamId { get; set; } = string.Empty; + + /// + /// Gets or sets the session identifier of the client that opened this live stream. + /// + public string SessionId { get; set; } = string.Empty; + + /// + /// Gets or sets the play session identifier associated with this live stream, + /// or an empty string when the client did not supply one. + /// + public string PlaySessionId { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the pod that currently holds this live stream open. + /// + public string OwnerPod { get; set; } = string.Empty; + + /// + /// Gets or sets the UTC time at which this record was created. + /// + public DateTime OpenedAtUtc { get; set; } +} diff --git a/MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs b/MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs index 4626a677d..a92ab8098 100644 --- a/MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs +++ b/MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs @@ -34,4 +34,16 @@ public sealed class NullTranscodeSessionStore : ITranscodeSessionStore /// public Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default) => Task.FromResult>(Array.Empty()); + + /// + 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; } diff --git a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsHaTakeoverTests.cs b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsHaTakeoverTests.cs index e0b0f492c..5fed2bf0b 100644 --- a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsHaTakeoverTests.cs +++ b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsHaTakeoverTests.cs @@ -185,6 +185,15 @@ namespace Jellyfin.Api.Tests.Controllers } } + 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 { diff --git a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsSessionRegistrationTests.cs b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsSessionRegistrationTests.cs index 1c7d9dbd2..b95a73787 100644 --- a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsSessionRegistrationTests.cs +++ b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsSessionRegistrationTests.cs @@ -197,6 +197,15 @@ namespace Jellyfin.Api.Tests.Controllers } } + 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 { diff --git a/tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs b/tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs index 0d9c4d00d..68ce20cf5 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs @@ -18,6 +18,7 @@ public sealed class InMemoryTranscodeSessionStore : ITranscodeSessionStore public static readonly TimeSpan DefaultLeaseDuration = TimeSpan.FromSeconds(30); private readonly Dictionary _sessions = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _liveStreams = new(StringComparer.OrdinalIgnoreCase); private readonly Lock _lock = new(); /// @@ -103,6 +104,55 @@ public sealed class InMemoryTranscodeSessionStore : ITranscodeSessionStore } } + /// + public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _liveStreams[MakeLiveStreamKey(session.LiveStreamId, session.SessionId)] = session; + if (!string.IsNullOrEmpty(session.PlaySessionId)) + { + _liveStreams[MakeLiveStreamKey(session.LiveStreamId, session.PlaySessionId)] = session; + } + } + + return Task.CompletedTask; + } + + /// + public Task TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _liveStreams.TryGetValue(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId), out var session); + return Task.FromResult(session); + } + } + + /// + public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (_liveStreams.TryGetValue(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId), out var session)) + { + _liveStreams.Remove(MakeLiveStreamKey(liveStreamId, session.SessionId)); + if (!string.IsNullOrEmpty(session.PlaySessionId)) + { + _liveStreams.Remove(MakeLiveStreamKey(liveStreamId, session.PlaySessionId)); + } + } + else + { + _liveStreams.Remove(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId)); + } + } + + return Task.CompletedTask; + } + + private static string MakeLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId) + => liveStreamId + "\x00" + sessionIdOrPlaySessionId; private static TranscodeSession Clone(TranscodeSession source) => new TranscodeSession { diff --git a/tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cs b/tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cs index 873fb5662..dad52ef4f 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cs @@ -128,6 +128,100 @@ public class RedisTranscodeSessionStoreTests Assert.Equal(1, successCount); } + /// + /// Verifies that stores a live stream + /// record that can be retrieved by session id. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task SetLiveStreamAsync_CanBeRetrievedBySessionId() + { + var store = new InMemoryTranscodeSessionStore(); + var liveStream = new LiveStreamSession + { + LiveStreamId = "stream-1", + SessionId = "session-a", + PlaySessionId = "play-session-a", + OwnerPod = "pod-a", + OpenedAtUtc = DateTime.UtcNow, + }; + + await store.SetLiveStreamAsync(liveStream); + + var result = await store.TryGetLiveStreamAsync("stream-1", "session-a"); + Assert.NotNull(result); + Assert.Equal("session-a", result.SessionId); + Assert.Equal("pod-a", result.OwnerPod); + } + + /// + /// Verifies that stores a live stream + /// record that can be retrieved by play session id. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task SetLiveStreamAsync_CanBeRetrievedByPlaySessionId() + { + var store = new InMemoryTranscodeSessionStore(); + var liveStream = new LiveStreamSession + { + LiveStreamId = "stream-2", + SessionId = "session-b", + PlaySessionId = "play-session-b", + OwnerPod = "pod-a", + OpenedAtUtc = DateTime.UtcNow, + }; + + await store.SetLiveStreamAsync(liveStream); + + var result = await store.TryGetLiveStreamAsync("stream-2", "play-session-b"); + Assert.NotNull(result); + Assert.Equal("session-b", result.SessionId); + } + + /// + /// Verifies that removes the live + /// stream record so that subsequent lookups by either session id or play session id return null. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task DeleteLiveStreamAsync_RemovesBothKeys() + { + var store = new InMemoryTranscodeSessionStore(); + var liveStream = new LiveStreamSession + { + LiveStreamId = "stream-3", + SessionId = "session-c", + PlaySessionId = "play-session-c", + OwnerPod = "pod-a", + OpenedAtUtc = DateTime.UtcNow, + }; + + await store.SetLiveStreamAsync(liveStream); + await store.DeleteLiveStreamAsync("stream-3", "session-c"); + + var bySessionId = await store.TryGetLiveStreamAsync("stream-3", "session-c"); + var byPlaySessionId = await store.TryGetLiveStreamAsync("stream-3", "play-session-c"); + + Assert.Null(bySessionId); + Assert.Null(byPlaySessionId); + } + + /// + /// Verifies that returns null when + /// no matching record exists. + /// + [Fact] + [Trait("Category", "UnitTest")] + public async Task TryGetLiveStreamAsync_WhenNotPresent_ReturnsNull() + { + var store = new InMemoryTranscodeSessionStore(); + + var result = await store.TryGetLiveStreamAsync("nonexistent-stream", "nonexistent-session"); + + Assert.Null(result); + } + /// /// Thread-safe, in-memory implementation of used within /// this test class to avoid a cross-project reference to Jellyfin.MediaEncoding.Tests. @@ -137,6 +231,7 @@ public class RedisTranscodeSessionStoreTests private static readonly TimeSpan DefaultLeaseDuration = TimeSpan.FromSeconds(30); private readonly Dictionary _sessions = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _liveStreams = new(StringComparer.OrdinalIgnoreCase); private readonly Lock _lock = new(); /// @@ -223,6 +318,55 @@ public class RedisTranscodeSessionStoreTests } } + /// + public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _liveStreams[MakeLiveStreamKey(session.LiveStreamId, session.SessionId)] = session; + if (!string.IsNullOrEmpty(session.PlaySessionId)) + { + _liveStreams[MakeLiveStreamKey(session.LiveStreamId, session.PlaySessionId)] = session; + } + } + + return Task.CompletedTask; + } + + /// + public Task TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _liveStreams.TryGetValue(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId), out var session); + return Task.FromResult(session); + } + } + + /// + public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (_liveStreams.TryGetValue(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId), out var session)) + { + _liveStreams.Remove(MakeLiveStreamKey(liveStreamId, session.SessionId)); + if (!string.IsNullOrEmpty(session.PlaySessionId)) + { + _liveStreams.Remove(MakeLiveStreamKey(liveStreamId, session.PlaySessionId)); + } + } + else + { + _liveStreams.Remove(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId)); + } + } + + return Task.CompletedTask; + } + + private static string MakeLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId) + => liveStreamId + "\x00" + sessionIdOrPlaySessionId; private static TranscodeSession Clone(TranscodeSession source) => new TranscodeSession { diff --git a/tests/Jellyfin.Server.Implementations.Tests/ScheduledTasks/DeleteTranscodeFileTaskTests.cs b/tests/Jellyfin.Server.Implementations.Tests/ScheduledTasks/DeleteTranscodeFileTaskTests.cs index 90dd010d5..12eace679 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/ScheduledTasks/DeleteTranscodeFileTaskTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/ScheduledTasks/DeleteTranscodeFileTaskTests.cs @@ -404,6 +404,15 @@ public class DeleteTranscodeFileTaskTests } } + 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 { diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs index a5a67046d..8043ed406 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs @@ -8,6 +8,7 @@ using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Session; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; @@ -36,7 +37,8 @@ public class SessionManagerTests Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); await Assert.ThrowsAsync(exceptionType, () => sessionManager.GetAuthorizationToken( new User("test", "default", "default"), @@ -63,7 +65,8 @@ public class SessionManagerTests Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); await Assert.ThrowsAsync(exceptionType, () => sessionManager.AuthenticateNewSessionInternal(authenticationRequest, false)); }