From bb26760011dd74bb31a4fa23e3345edab6c71f85 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 26 Sep 2026 22:11:03 +1000 Subject: [PATCH] fix(session): keep the maintenance sweeps and capabilities with the session owner --- .../Session/RemoteSessionController.cs | 6 +- .../Session/SessionManager.cs | 100 ++++++++++++++++-- Jellyfin.Api/Controllers/SessionController.cs | 6 +- .../Session/ISessionManager.cs | 3 +- .../Session/RoutedCapabilities.cs | 25 +++++ 5 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 MediaBrowser.Controller/Session/RoutedCapabilities.cs diff --git a/Emby.Server.Implementations/Session/RemoteSessionController.cs b/Emby.Server.Implementations/Session/RemoteSessionController.cs index eb134be469..278de9c08d 100644 --- a/Emby.Server.Implementations/Session/RemoteSessionController.cs +++ b/Emby.Server.Implementations/Session/RemoteSessionController.cs @@ -30,17 +30,19 @@ public sealed class RemoteSessionController : ISessionController /// The instance holding the connection. /// The session identifier. /// Whether the owner reported the session as controllable. - public RemoteSessionController(IPodMessageBus bus, ILogger logger, string ownerPod, string sessionId, bool supportsMediaControl) + /// Whether the owner reported that it holds the session's connection. + public RemoteSessionController(IPodMessageBus bus, ILogger logger, string ownerPod, string sessionId, bool supportsMediaControl, bool holdsConnection) { _bus = bus; _logger = logger; _ownerPod = ownerPod; _sessionId = sessionId; SupportsMediaControl = supportsMediaControl; + IsSessionActive = holdsConnection; } /// - public bool IsSessionActive => true; + public bool IsSessionActive { get; } /// public bool SupportsMediaControl { get; } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 58a23c16b6..0b5ac43d22 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -523,7 +523,7 @@ namespace Emby.Server.Implementations.Session Capabilities = dto.Capabilities?.ToClientCapabilities() }; - session.AddController(new RemoteSessionController(_podMessageBus, _logger, entry.OwnerPod, dto.Id, dto.SupportsMediaControl)); + session.AddController(new RemoteSessionController(_podMessageBus, _logger, entry.OwnerPod, dto.Id, dto.SupportsMediaControl, entry.HoldsConnection)); return session; } @@ -540,6 +540,8 @@ namespace Emby.Server.Implementations.Session return Task.FromResult(OnRoutedAdditionalUserChange(message)); case RoutedNowViewingItem.Kind: return Task.FromResult(OnRoutedNowViewingItem(message)); + case RoutedCapabilities.Kind: + return Task.FromResult(OnRoutedCapabilities(message)); case RoutedPlaybackReport.StartKind: case RoutedPlaybackReport.ProgressKind: case RoutedPlaybackReport.StoppedKind: @@ -621,6 +623,23 @@ namespace Emby.Server.Implementations.Session return true; } + private bool OnRoutedCapabilities(PodMessage message) + { + var routed = JsonSerializer.Deserialize(message.Payload, JsonDefaults.Options); + var session = routed?.Capabilities is null + ? null + : Sessions.FirstOrDefault(i => string.Equals(i.Id, routed.SessionId, StringComparison.Ordinal)); + + if (session is null) + { + return false; + } + + ReportCapabilities(session, routed.Capabilities, true); + + return true; + } + private async Task OnRoutedPlaybackReport(PodMessage message) { try @@ -677,7 +696,14 @@ namespace Emby.Server.Implementations.Session if (!routed) { - _logger.LogWarning("Instance {OwnerPod} did not apply the {Kind} report for session {Session}; it is applied here instead.", entry.OwnerPod, kind, sessionId); + // Delivery is at-least-once: an owner that applied the report and then failed to + // acknowledge it has it applied here as well, so a stop can be applied twice. A + // duplicate re-saves the same position; dropping the report loses it outright. + _logger.LogWarning( + "Instance {OwnerPod} did not acknowledge the {Kind} report for session {Session}; it is applied here as well, which may repeat one the owner already applied.", + entry.OwnerPod, + kind, + sessionId); } return routed; @@ -989,6 +1015,29 @@ namespace Emby.Server.Implementations.Session return users; } + // The sweeps stop playback and save user data, so they act only on sessions this instance owns. + // A copy left on a non-owner stops checking in as soon as reports route away, and stopping it would + // end the owner's live playback and save the stale position the copy last saw. + private async Task OwnsSessionAsync(SessionInfo session) + { + if (!_directoryEnabled) + { + return true; + } + + try + { + var entry = await _sessionDirectory.GetAsync(session.Id).ConfigureAwait(false); + + return entry is null || string.Equals(entry.OwnerPod, _podMessageBus.PodId, StringComparison.Ordinal); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read the owner of session {Session}; it is left alone.", session.Id); + return false; + } + } + private void StartCheckTimers() { _idleTimer ??= new Timer(CheckForIdlePlayback, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); @@ -1033,6 +1082,11 @@ namespace Emby.Server.Implementations.Session foreach (var session in idle) { + if (!await OwnsSessionAsync(session).ConfigureAwait(false)) + { + continue; + } + _logger.LogDebug("Session {0} has gone idle while playing", session.Id); try @@ -1067,6 +1121,11 @@ namespace Emby.Server.Implementations.Session foreach (var session in inactiveSessions) { + if (!await OwnsSessionAsync(session).ConfigureAwait(false)) + { + continue; + } + _logger.LogDebug("Session {Session} has been inactive for {InactiveTime} minutes. Stopping it.", session.Id, _config.Configuration.InactiveSessionThreshold); try @@ -2399,19 +2458,34 @@ namespace Emby.Server.Implementations.Session /// The controlling session identifier. /// The session identifier. /// The capabilities. + /// A task representing the operation. /// The controlling user is not allowed to control the session. - public void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities) + public async Task ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities) { CheckDisposed(); - var session = GetSession(sessionId); + var session = await GetSessionForControl(sessionId).ConfigureAwait(false); if (!string.IsNullOrEmpty(controllingSessionId)) { - AssertCanControl(session, GetSession(controllingSessionId)); + AssertCanControl(session, await GetSessionForControl(controllingSessionId).ConfigureAwait(false)); } - ReportCapabilities(session, capabilities, true); + // Capabilities decide whether the session is offered for remote control, and they are held in + // the owner's process, so the report has to reach the instance everyone else is shown. + var payload = new RoutedCapabilities { SessionId = sessionId, Capabilities = capabilities }; + var routed = await TryRouteToOwnerAsync(sessionId, RoutedCapabilities.Kind, payload, CancellationToken.None).ConfigureAwait(false); + + var local = GetSession(sessionId, false); + if (local is not null) + { + ReportCapabilities(local, capabilities, true); + } + else if (!routed) + { + throw new ResourceNotFoundException( + string.Format(CultureInfo.InvariantCulture, "Session {0} not found.", sessionId)); + } } private void ReportCapabilities( @@ -2431,6 +2505,10 @@ namespace Emby.Server.Implementations.Session }); _deviceManager.SaveCapabilities(session.DeviceId, capabilities); + + // Capabilities decide whether the session is listed as controllable, so the change is not + // left to the throttle. + _ = PublishToDirectoryNowAsync(session); } } @@ -2523,11 +2601,11 @@ namespace Emby.Server.Implementations.Session return; } - var local = GetSession(sessionId, false); - if (local is not null) - { - SetNowViewingItem(local, itemId); - } + var local = GetSession(sessionId, false) + ?? throw new ResourceNotFoundException( + string.Format(CultureInfo.InvariantCulture, "Session {0} not found.", sessionId)); + + SetNowViewingItem(local, itemId); } private void SetNowViewingItem(SessionInfo session, string itemId) diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 3e4fe3367e..48045e6220 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -365,13 +365,13 @@ public class SessionController : BaseJellyfinApiController id = currentSessionId; } - _sessionManager.ReportCapabilities(currentSessionId, id, new ClientCapabilities + await _sessionManager.ReportCapabilities(currentSessionId, id, new ClientCapabilities { PlayableMediaTypes = playableMediaTypes, SupportedCommands = supportedCommands, SupportsMediaControl = supportsMediaControl, SupportsPersistentIdentifier = supportsPersistentIdentifier - }); + }).ConfigureAwait(false); return NoContent(); } @@ -395,7 +395,7 @@ public class SessionController : BaseJellyfinApiController id = currentSessionId; } - _sessionManager.ReportCapabilities(currentSessionId, id, capabilities.ToClientCapabilities()); + await _sessionManager.ReportCapabilities(currentSessionId, id, capabilities.ToClientCapabilities()).ConfigureAwait(false); return NoContent(); } diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index 70609e95d1..fce9f7853e 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -278,7 +278,8 @@ namespace MediaBrowser.Controller.Session /// The controlling session identifier. /// The session identifier. /// The capabilities. - void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities); + /// A task representing the operation. + Task ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities); /// /// Reports the transcoding information. diff --git a/MediaBrowser.Controller/Session/RoutedCapabilities.cs b/MediaBrowser.Controller/Session/RoutedCapabilities.cs new file mode 100644 index 0000000000..4e2ffd406f --- /dev/null +++ b/MediaBrowser.Controller/Session/RoutedCapabilities.cs @@ -0,0 +1,25 @@ +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Session; + +/// +/// A capabilities report for a session held by another instance, carried as a . +/// The calling instance has already authorized it. +/// +public sealed class RoutedCapabilities +{ + /// + /// The this payload travels under. + /// + public const string Kind = "Capabilities"; + + /// + /// Gets or sets the session the report applies to. + /// + public string SessionId { get; set; } = string.Empty; + + /// + /// Gets or sets the reported capabilities. + /// + public ClientCapabilities? Capabilities { get; set; } +}