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