fix(session): keep the maintenance sweeps and capabilities with the session owner

This commit is contained in:
2026-09-26 22:11:03 +10:00
parent af4e44173f
commit bb26760011
5 changed files with 123 additions and 17 deletions
@@ -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<RoutedCapabilities>(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<bool> 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<bool> 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
/// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="capabilities">The capabilities.</param>
/// <returns>A task representing the operation.</returns>
/// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
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)