feat(session): record open live streams in the session store

Live stream ownership is only tracked in process memory, so no replica can tell
which pod holds a stream open once that pod is gone.

- persist a LiveStreamSession record when the live stream mappings change
- delete the record when the stream is closed
- keep closing the stream when the store is unreachable
This commit is contained in:
2026-09-11 23:56:35 +10:00
parent 6ccabd4c72
commit e3571eb2a4
4 changed files with 113 additions and 8 deletions
@@ -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 MediaBrowser.Model.Dto;
using MediaBrowser.Model.Session;
@@ -44,7 +45,8 @@ public class IdlePlaybackTests
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>());
Mock.Of<IHostApplicationLifetime>(),
new NullTranscodeSessionStore());
var session = await sessionManager.LogSessionActivity(
"Test Client",
"1.0.0",
@@ -0,0 +1,66 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.SessionManager;
public class LiveStreamHaRecordTests
{
[Fact]
public async Task CloseLiveStreamIfNeededAsync_Should_DeleteDurableRecord()
{
var store = new Mock<ITranscodeSessionStore>();
var mediaSourceManager = new Mock<IMediaSourceManager>();
await using var sessionManager = CreateSessionManager(store.Object, mediaSourceManager.Object);
await sessionManager.CloseLiveStreamIfNeededAsync("stream-1", "session-1");
store.Verify(s => s.DeleteLiveStreamAsync("stream-1", "session-1", It.IsAny<CancellationToken>()), Times.Once);
mediaSourceManager.Verify(m => m.CloseLiveStream("stream-1"), Times.Once);
}
[Fact]
public async Task CloseLiveStreamIfNeededAsync_Should_CloseStream_WhenDurableStoreFails()
{
var store = new Mock<ITranscodeSessionStore>();
store.Setup(s => s.DeleteLiveStreamAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("redis unreachable"));
var mediaSourceManager = new Mock<IMediaSourceManager>();
await using var sessionManager = CreateSessionManager(store.Object, mediaSourceManager.Object);
await sessionManager.CloseLiveStreamIfNeededAsync("stream-2", "session-2");
mediaSourceManager.Verify(m => m.CloseLiveStream("stream-2"), Times.Once);
}
private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(
ITranscodeSessionStore transcodeSessionStore,
IMediaSourceManager mediaSourceManager)
=> new Emby.Server.Implementations.Session.SessionManager(
NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance,
Mock.Of<IEventManager>(),
Mock.Of<IUserDataManager>(),
Mock.Of<IServerConfigurationManager>(),
Mock.Of<ILibraryManager>(),
Mock.Of<IUserManager>(),
Mock.Of<IMusicManager>(),
Mock.Of<IDtoService>(),
Mock.Of<IImageProcessor>(),
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
mediaSourceManager,
Mock.Of<IHostApplicationLifetime>(),
transcodeSessionStore);
}
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
@@ -41,7 +42,8 @@ public class SessionManagerTests
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>());
Mock.Of<IHostApplicationLifetime>(),
new NullTranscodeSessionStore());
await Assert.ThrowsAsync(exceptionType, () => sessionManager.GetAuthorizationToken(
new User("test", "default", "default"),
@@ -68,7 +70,8 @@ public class SessionManagerTests
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>());
Mock.Of<IHostApplicationLifetime>(),
new NullTranscodeSessionStore());
await Assert.ThrowsAsync(exceptionType, () => sessionManager.AuthenticateNewSessionInternal(authenticationRequest, false));
}
@@ -238,7 +241,8 @@ public class SessionManagerTests
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>());
Mock.Of<IHostApplicationLifetime>(),
new NullTranscodeSessionStore());
}
// All sessions are logged with the same client and device id on purpose, those values are taken