feat(session): share the session directory between instances
Publish every session to valkey with the instance holding it, and route a command for a non-local session to its owner over a per-instance pub/sub channel. Entries expire, so a dead instance leaves the directory.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Emby.Server.Implementations.Session;
|
||||
|
||||
/// <summary>
|
||||
/// A Redis pub/sub <see cref="IPodMessageBus"/>. Every instance subscribes to a channel named after
|
||||
/// itself, which keeps addressed delivery working without the instances being routable to each other.
|
||||
/// </summary>
|
||||
public sealed class RedisPodMessageBus : IPodMessageBus
|
||||
{
|
||||
private const string ChannelPrefix = "jellyfin:pod:";
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||
|
||||
private readonly ISubscriber _subscriber;
|
||||
private readonly ILogger<RedisPodMessageBus> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedisPodMessageBus"/> class.
|
||||
/// </summary>
|
||||
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public RedisPodMessageBus(IConnectionMultiplexer redis, ILogger<RedisPodMessageBus> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(redis);
|
||||
|
||||
_subscriber = redis.GetSubscriber();
|
||||
_logger = logger;
|
||||
PodId = PodIdentity.Current;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string PodId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(string targetPod, PodMessage message)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(targetPod);
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
message.OriginPod = PodId;
|
||||
|
||||
try
|
||||
{
|
||||
_subscriber.Publish(
|
||||
RedisChannel.Literal(ChannelPrefix + targetPod),
|
||||
JsonSerializer.Serialize(message, _jsonOptions),
|
||||
CommandFlags.FireAndForget);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send a {Kind} message to {TargetPod}.", message.Kind, targetPod);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Subscribe(Func<PodMessage, Task> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
try
|
||||
{
|
||||
_subscriber.Subscribe(RedisChannel.Literal(ChannelPrefix + PodId), (_, value) => Dispatch(handler, value));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to subscribe to {PodId}; messages routed here are dropped.", PodId);
|
||||
}
|
||||
}
|
||||
|
||||
private async void Dispatch(Func<PodMessage, Task> handler, RedisValue value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = JsonSerializer.Deserialize<PodMessage>(value.ToString(), _jsonOptions);
|
||||
if (message is not null)
|
||||
{
|
||||
await handler(message).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to handle a message routed to this instance.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Emby.Server.Implementations.Session;
|
||||
|
||||
/// <summary>
|
||||
/// A Redis-backed <see cref="ISessionDirectory"/>. Each entry is a key with an expiry, so the sessions of
|
||||
/// an instance that stops refreshing them disappear on their own.
|
||||
/// </summary>
|
||||
public sealed class RedisSessionDirectory : ISessionDirectory
|
||||
{
|
||||
private const string KeyPrefix = "jellyfin:session:";
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IDatabase _db;
|
||||
private readonly SessionDirectoryOptions _options;
|
||||
private readonly ILogger<RedisSessionDirectory> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedisSessionDirectory"/> class.
|
||||
/// </summary>
|
||||
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||
/// <param name="options">The session directory configuration options.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public RedisSessionDirectory(
|
||||
IConnectionMultiplexer redis,
|
||||
IOptions<SessionDirectoryOptions> options,
|
||||
ILogger<RedisSessionDirectory> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(redis);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_redis = redis;
|
||||
_db = redis.GetDatabase();
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private TimeSpan EntryTtl => TimeSpan.FromSeconds(Math.Max(1, _options.EntryTtlSeconds));
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PublishAsync(SessionDirectoryEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
var sessionId = entry.Session?.Id;
|
||||
if (string.IsNullOrEmpty(sessionId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _db.StringSetAsync(KeyPrefix + sessionId, JsonSerializer.Serialize(entry, _jsonOptions), EntryTtl).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to publish session {SessionId}; it stays invisible to the other instances.", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _db.KeyDeleteAsync(KeyPrefix + sessionId).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to remove session {SessionId}; it expires on its own.", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SessionDirectoryEntry>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = new List<SessionDirectoryEntry>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var server in _redis.GetServers())
|
||||
{
|
||||
if (!server.IsConnected)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var keys = new List<RedisKey>();
|
||||
await foreach (var key in server.KeysAsync(database: _db.Database, pattern: KeyPrefix + "*", pageSize: 1000).WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
keys.Add(key);
|
||||
}
|
||||
|
||||
var values = await Task.WhenAll(keys.Select(key => _db.StringGetAsync(key))).ConfigureAwait(false);
|
||||
|
||||
foreach (var raw in values)
|
||||
{
|
||||
if (!raw.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entry = JsonSerializer.Deserialize<SessionDirectoryEntry>(raw.ToString(), _jsonOptions);
|
||||
if (entry?.Session is not null)
|
||||
{
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize a session directory entry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Degrade to the sessions this instance holds rather than failing the request outright.
|
||||
_logger.LogWarning(ex, "Failed to read the session directory; only local sessions are reported.");
|
||||
return Array.Empty<SessionDirectoryEntry>();
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Session;
|
||||
|
||||
namespace Emby.Server.Implementations.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for the websocket of a session another instance holds: messages are forwarded to that
|
||||
/// instance, which writes them to the connection it owns.
|
||||
/// </summary>
|
||||
public sealed class RemoteSessionController : ISessionController
|
||||
{
|
||||
private readonly IPodMessageBus _bus;
|
||||
private readonly string _ownerPod;
|
||||
private readonly string _sessionId;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RemoteSessionController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="bus">The cross-instance bus.</param>
|
||||
/// <param name="ownerPod">The instance holding the connection.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="isSessionActive">Whether the owner reported the session as active.</param>
|
||||
/// <param name="supportsMediaControl">Whether the owner reported the session as controllable.</param>
|
||||
public RemoteSessionController(IPodMessageBus bus, string ownerPod, string sessionId, bool isSessionActive, bool supportsMediaControl)
|
||||
{
|
||||
_bus = bus;
|
||||
_ownerPod = ownerPod;
|
||||
_sessionId = sessionId;
|
||||
IsSessionActive = isSessionActive;
|
||||
SupportsMediaControl = supportsMediaControl;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsSessionActive { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool SupportsMediaControl { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SendMessage<T>(SessionMessageType name, Guid messageId, T data, CancellationToken cancellationToken)
|
||||
{
|
||||
var routed = new RoutedSessionMessage
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
MessageType = name,
|
||||
MessageId = messageId,
|
||||
Data = JsonSerializer.Serialize(data, JsonDefaults.Options)
|
||||
};
|
||||
|
||||
_bus.Publish(
|
||||
_ownerPod,
|
||||
new PodMessage
|
||||
{
|
||||
Kind = RoutedSessionMessage.Kind,
|
||||
Payload = JsonSerializer.Serialize(routed, JsonDefaults.Options)
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data;
|
||||
@@ -15,6 +16,7 @@ using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Entities.Security;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Events;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
@@ -39,6 +41,7 @@ using MediaBrowser.Model.SyncPlay;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
|
||||
|
||||
namespace Emby.Server.Implementations.Session
|
||||
@@ -60,6 +63,9 @@ namespace Emby.Server.Implementations.Session
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
private readonly ISessionDirectory _sessionDirectory;
|
||||
private readonly IPodMessageBus _podMessageBus;
|
||||
private readonly SessionDirectoryOptions _sessionDirectoryOptions;
|
||||
private readonly CancellationTokenRegistration _shutdownCallback;
|
||||
private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -69,6 +75,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
private Timer _idleTimer;
|
||||
private Timer _inactiveTimer;
|
||||
private Timer _directoryTimer;
|
||||
|
||||
private DtoOptions _itemInfoDtoOptions;
|
||||
private bool _disposed;
|
||||
@@ -89,6 +96,9 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="hostApplicationLifetime">Instance of <see cref="IHostApplicationLifetime"/> interface.</param>
|
||||
/// <param name="sessionDirectory">Instance of <see cref="ISessionDirectory"/> interface.</param>
|
||||
/// <param name="podMessageBus">Instance of <see cref="IPodMessageBus"/> interface.</param>
|
||||
/// <param name="sessionDirectoryOptions">The session directory options.</param>
|
||||
public SessionManager(
|
||||
ILogger<SessionManager> logger,
|
||||
IEventManager eventManager,
|
||||
@@ -102,7 +112,10 @@ namespace Emby.Server.Implementations.Session
|
||||
IServerApplicationHost appHost,
|
||||
IDeviceManager deviceManager,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IHostApplicationLifetime hostApplicationLifetime)
|
||||
IHostApplicationLifetime hostApplicationLifetime,
|
||||
ISessionDirectory sessionDirectory,
|
||||
IPodMessageBus podMessageBus,
|
||||
IOptions<SessionDirectoryOptions> sessionDirectoryOptions)
|
||||
{
|
||||
_logger = logger;
|
||||
_eventManager = eventManager;
|
||||
@@ -116,9 +129,19 @@ namespace Emby.Server.Implementations.Session
|
||||
_appHost = appHost;
|
||||
_deviceManager = deviceManager;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_sessionDirectory = sessionDirectory;
|
||||
_podMessageBus = podMessageBus;
|
||||
_sessionDirectoryOptions = sessionDirectoryOptions.Value;
|
||||
_shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping);
|
||||
|
||||
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
|
||||
|
||||
if (_sessionDirectory is not NullSessionDirectory)
|
||||
{
|
||||
_podMessageBus.Subscribe(OnPodMessage);
|
||||
var interval = TimeSpan.FromSeconds(Math.Max(1, _sessionDirectoryOptions.RefreshIntervalSeconds));
|
||||
_directoryTimer = new Timer(RefreshSessionDirectory, null, interval, interval);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -218,6 +241,8 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
_eventManager.Publish(new SessionEndedEventArgs(info));
|
||||
|
||||
await RemoveFromDirectoryAsync(info).ConfigureAwait(false);
|
||||
|
||||
await info.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -288,6 +313,8 @@ namespace Emby.Server.Implementations.Session
|
||||
});
|
||||
}
|
||||
|
||||
await PublishToDirectoryAsync(session).ConfigureAwait(false);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -302,6 +329,134 @@ namespace Emby.Server.Implementations.Session
|
||||
SessionInfo = session
|
||||
},
|
||||
_logger);
|
||||
|
||||
// The session only becomes remote-controllable once it has a connection, so the other
|
||||
// instances have to be told again now that it does.
|
||||
_ = PublishToDirectoryAsync(session);
|
||||
}
|
||||
|
||||
private bool DirectoryEnabled => _sessionDirectory is not NullSessionDirectory;
|
||||
|
||||
private async Task PublishToDirectoryAsync(SessionInfo session)
|
||||
{
|
||||
if (!DirectoryEnabled || string.IsNullOrEmpty(session.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _sessionDirectory.PublishAsync(
|
||||
new SessionDirectoryEntry
|
||||
{
|
||||
OwnerPod = _podMessageBus.PodId,
|
||||
Session = ToSessionInfoDto(session)
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error publishing session {Session} to the directory.", session.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask RemoveFromDirectoryAsync(SessionInfo session)
|
||||
{
|
||||
if (!DirectoryEnabled || string.IsNullOrEmpty(session.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _sessionDirectory.RemoveAsync(session.Id).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async void RefreshSessionDirectory(object state)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var session in _activeConnections.Values)
|
||||
{
|
||||
await PublishToDirectoryAsync(session).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error refreshing the session directory.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SessionDirectoryEntry>> GetRemoteEntriesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!DirectoryEnabled)
|
||||
{
|
||||
return Array.Empty<SessionDirectoryEntry>();
|
||||
}
|
||||
|
||||
var entries = await _sessionDirectory.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return entries
|
||||
.Where(entry => entry.Session is not null
|
||||
&& !string.Equals(entry.OwnerPod, _podMessageBus.PodId, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<SessionInfo> GetRemoteSession(string sessionId)
|
||||
{
|
||||
var entries = await GetRemoteEntriesAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
var entry = entries.FirstOrDefault(i => string.Equals(i.Session.Id, sessionId, StringComparison.Ordinal));
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dto = entry.Session;
|
||||
var session = new SessionInfo(this, _logger)
|
||||
{
|
||||
Id = dto.Id,
|
||||
UserId = dto.UserId,
|
||||
UserName = dto.UserName,
|
||||
Client = dto.Client,
|
||||
DeviceId = dto.DeviceId,
|
||||
DeviceName = dto.DeviceName,
|
||||
DeviceType = dto.DeviceType,
|
||||
ApplicationVersion = dto.ApplicationVersion,
|
||||
RemoteEndPoint = dto.RemoteEndPoint,
|
||||
LastActivityDate = dto.LastActivityDate,
|
||||
ServerId = dto.ServerId,
|
||||
AdditionalUsers = dto.AdditionalUsers ?? [],
|
||||
Capabilities = dto.Capabilities?.ToClientCapabilities()
|
||||
};
|
||||
|
||||
session.AddController(new RemoteSessionController(_podMessageBus, entry.OwnerPod, dto.Id, dto.IsActive, dto.SupportsMediaControl));
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private async Task OnPodMessage(PodMessage message)
|
||||
{
|
||||
if (!string.Equals(message.Kind, RoutedSessionMessage.Kind, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var routed = JsonSerializer.Deserialize<RoutedSessionMessage>(message.Payload, JsonDefaults.Options);
|
||||
if (routed is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, routed.SessionId, StringComparison.Ordinal));
|
||||
if (session is null)
|
||||
{
|
||||
_logger.LogDebug("Session {Session} was routed here but is no longer held by this instance.", routed.SessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
using var data = JsonDocument.Parse(routed.Data);
|
||||
foreach (var controller in session.SessionControllers)
|
||||
{
|
||||
await controller.SendMessage(routed.MessageType, routed.MessageId, data.RootElement, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -1219,10 +1374,12 @@ namespace Emby.Server.Implementations.Session
|
||||
return session;
|
||||
}
|
||||
|
||||
private SessionInfo GetSessionToRemoteControl(string sessionId)
|
||||
// A session held by another instance is reachable too: the returned SessionInfo carries a
|
||||
// controller that forwards to its owner instead of writing to a local connection.
|
||||
private async Task<SessionInfo> GetSessionToRemoteControl(string sessionId)
|
||||
{
|
||||
// Accept either device id or session id
|
||||
var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId, StringComparison.Ordinal));
|
||||
var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId, StringComparison.Ordinal))
|
||||
?? await GetRemoteSession(sessionId).ConfigureAwait(false);
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
@@ -1291,19 +1448,19 @@ namespace Emby.Server.Implementations.Session
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SendGeneralCommand(string controllingSessionId, string sessionId, GeneralCommand command, CancellationToken cancellationToken)
|
||||
public async Task SendGeneralCommand(string controllingSessionId, string sessionId, GeneralCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var session = GetSessionToRemoteControl(sessionId);
|
||||
var session = await GetSessionToRemoteControl(sessionId).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
var controllingSession = GetSession(controllingSessionId);
|
||||
var controllingSession = await GetSessionToRemoteControl(controllingSessionId).ConfigureAwait(false);
|
||||
AssertCanControl(session, controllingSession);
|
||||
}
|
||||
|
||||
return SendMessageToSession(session, SessionMessageType.GeneralCommand, command, cancellationToken);
|
||||
await SendMessageToSession(session, SessionMessageType.GeneralCommand, command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task SendMessageToSession<T>(SessionInfo session, SessionMessageType name, T data, CancellationToken cancellationToken)
|
||||
@@ -1340,7 +1497,7 @@ namespace Emby.Server.Implementations.Session
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var session = GetSessionToRemoteControl(sessionId);
|
||||
var session = await GetSessionToRemoteControl(sessionId).ConfigureAwait(false);
|
||||
|
||||
var user = session.UserId.IsEmpty() ? null : _userManager.GetUserById(session.UserId);
|
||||
|
||||
@@ -1410,7 +1567,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
var controllingSession = GetSession(controllingSessionId);
|
||||
var controllingSession = await GetSessionToRemoteControl(controllingSessionId).ConfigureAwait(false);
|
||||
AssertCanControl(session, controllingSession);
|
||||
if (!controllingSession.UserId.IsEmpty())
|
||||
{
|
||||
@@ -1521,15 +1678,15 @@ namespace Emby.Server.Implementations.Session
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SendPlaystateCommand(string controllingSessionId, string sessionId, PlaystateRequest command, CancellationToken cancellationToken)
|
||||
public async Task SendPlaystateCommand(string controllingSessionId, string sessionId, PlaystateRequest command, CancellationToken cancellationToken)
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var session = GetSessionToRemoteControl(sessionId);
|
||||
var session = await GetSessionToRemoteControl(sessionId).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
var controllingSession = GetSession(controllingSessionId);
|
||||
var controllingSession = await GetSessionToRemoteControl(controllingSessionId).ConfigureAwait(false);
|
||||
AssertCanControl(session, controllingSession);
|
||||
if (!controllingSession.UserId.IsEmpty())
|
||||
{
|
||||
@@ -1537,7 +1694,7 @@ namespace Emby.Server.Implementations.Session
|
||||
}
|
||||
}
|
||||
|
||||
return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken);
|
||||
await SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
|
||||
@@ -2060,14 +2217,21 @@ namespace Emby.Server.Implementations.Session
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<SessionInfoDto> GetSessions(
|
||||
public async Task<IReadOnlyList<SessionInfoDto>> GetSessions(
|
||||
Guid userId,
|
||||
string deviceId,
|
||||
int? activeWithinSeconds,
|
||||
Guid? controllableUserToCheck,
|
||||
bool isApiKey)
|
||||
bool isApiKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = Sessions;
|
||||
var remote = await GetRemoteEntriesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IEnumerable<SessionInfoDto> result = Sessions
|
||||
.Select(ToSessionInfoDto)
|
||||
.Concat(remote.Select(entry => entry.Session))
|
||||
.OrderByDescending(i => i.LastActivityDate);
|
||||
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase));
|
||||
@@ -2115,7 +2279,7 @@ namespace Emby.Server.Implementations.Session
|
||||
if (!userCanControlOthers)
|
||||
{
|
||||
// User cannot control other user's sessions, validate user id.
|
||||
result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(userId));
|
||||
result = result.Where(i => i.UserId.IsEmpty() || ContainsUser(i, userId));
|
||||
}
|
||||
|
||||
result = result.Where(i =>
|
||||
@@ -2136,7 +2300,7 @@ namespace Emby.Server.Implementations.Session
|
||||
else if (!userIsAdmin)
|
||||
{
|
||||
// Request isn't from administrator, limit to "own" sessions.
|
||||
result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(userId));
|
||||
result = result.Where(i => i.UserId.IsEmpty() || ContainsUser(i, userId));
|
||||
}
|
||||
|
||||
if (!userIsAdmin)
|
||||
@@ -2159,7 +2323,18 @@ namespace Emby.Server.Implementations.Session
|
||||
result = result.Where(i => i.LastActivityDate >= minActiveDate);
|
||||
}
|
||||
|
||||
return result.Select(ToSessionInfoDto).ToList();
|
||||
return result.ToList();
|
||||
}
|
||||
|
||||
private static bool ContainsUser(SessionInfoDto session, Guid userId)
|
||||
{
|
||||
if (session.UserId.Equals(userId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return session.AdditionalUsers is not null
|
||||
&& session.AdditionalUsers.Any(i => i.UserId.Equals(userId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -2234,6 +2409,12 @@ namespace Emby.Server.Implementations.Session
|
||||
_inactiveTimer = null;
|
||||
}
|
||||
|
||||
if (_directoryTimer is not null)
|
||||
{
|
||||
await _directoryTimer.DisposeAsync().ConfigureAwait(false);
|
||||
_directoryTimer = null;
|
||||
}
|
||||
|
||||
await _shutdownCallback.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
_deviceManager.DeviceOptionsUpdated -= OnDeviceManagerDeviceOptionsUpdated;
|
||||
@@ -2257,6 +2438,7 @@ namespace Emby.Server.Implementations.Session
|
||||
// Close open websockets to allow Kestrel to shut down cleanly
|
||||
foreach (var session in _activeConnections.Values)
|
||||
{
|
||||
await RemoveFromDirectoryAsync(session).ConfigureAwait(false);
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user