#nullable disable
using System;
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;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
using Jellyfin.Data.Queries;
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;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Events.Authentication;
using MediaBrowser.Controller.Events.Session;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Library;
using MediaBrowser.Model.Querying;
using MediaBrowser.Model.Session;
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
{
///
/// Class SessionManager.
///
public sealed class SessionManager : ISessionManager, IAsyncDisposable
{
private readonly IUserDataManager _userDataManager;
private readonly IServerConfigurationManager _config;
private readonly ILogger _logger;
private readonly IEventManager _eventManager;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IMusicManager _musicManager;
private readonly IDtoService _dtoService;
private readonly IImageProcessor _imageProcessor;
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 bool _directoryEnabled;
private readonly CancellationTokenRegistration _shutdownCallback;
private readonly ConcurrentDictionary _activeConnections
= new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary _connectionEpochs = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _lastDirectoryPublish = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _directoryOwned = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary> _activeLiveStreamSessions
= new(StringComparer.OrdinalIgnoreCase);
private Timer _idleTimer;
private Timer _inactiveTimer;
private Timer _directoryTimer;
private int _refreshingDirectory;
private DtoOptions _itemInfoDtoOptions;
private bool _disposed;
///
/// Initializes a new instance of the class.
///
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// The session directory options.
public SessionManager(
ILogger logger,
IEventManager eventManager,
IUserDataManager userDataManager,
IServerConfigurationManager serverConfigurationManager,
ILibraryManager libraryManager,
IUserManager userManager,
IMusicManager musicManager,
IDtoService dtoService,
IImageProcessor imageProcessor,
IServerApplicationHost appHost,
IDeviceManager deviceManager,
IMediaSourceManager mediaSourceManager,
IHostApplicationLifetime hostApplicationLifetime,
ISessionDirectory sessionDirectory,
IPodMessageBus podMessageBus,
IOptions sessionDirectoryOptions)
{
_logger = logger;
_eventManager = eventManager;
_userDataManager = userDataManager;
_config = serverConfigurationManager;
_libraryManager = libraryManager;
_userManager = userManager;
_musicManager = musicManager;
_dtoService = dtoService;
_imageProcessor = imageProcessor;
_appHost = appHost;
_deviceManager = deviceManager;
_mediaSourceManager = mediaSourceManager;
_sessionDirectory = sessionDirectory;
_podMessageBus = podMessageBus;
_sessionDirectoryOptions = sessionDirectoryOptions.Value;
_shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping);
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
_directoryEnabled = _sessionDirectory is not NullSessionDirectory;
if (_directoryEnabled)
{
_podMessageBus.Subscribe(OnPodMessage);
var interval = TimeSpan.FromSeconds(Math.Max(1, _sessionDirectoryOptions.RefreshIntervalSeconds));
_directoryTimer = new Timer(RefreshSessionDirectory, null, interval, interval);
}
}
///
/// Occurs when playback has started.
///
public event EventHandler PlaybackStart;
///
/// Occurs when playback has progressed.
///
public event EventHandler PlaybackProgress;
///
/// Occurs when playback has stopped.
///
public event EventHandler PlaybackStopped;
///
public event EventHandler SessionStarted;
///
public event EventHandler CapabilitiesChanged;
///
public event EventHandler SessionEnded;
///
public event EventHandler SessionActivity;
///
public event EventHandler SessionControllerConnected;
///
/// Gets all connections.
///
/// All connections.
public IEnumerable Sessions => _activeConnections.Values.OrderByDescending(c => c.LastActivityDate);
private void OnDeviceManagerDeviceOptionsUpdated(object sender, GenericEventArgs> e)
{
foreach (var session in Sessions)
{
if (string.Equals(session.DeviceId, e.Argument.Item1, StringComparison.Ordinal))
{
if (!string.IsNullOrWhiteSpace(e.Argument.Item2.CustomName))
{
session.HasCustomDeviceName = true;
session.DeviceName = e.Argument.Item2.CustomName;
}
else
{
session.HasCustomDeviceName = false;
}
}
}
}
private void CheckDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private void OnSessionStarted(SessionInfo info)
{
if (!string.IsNullOrEmpty(info.DeviceId))
{
var capabilities = _deviceManager.GetCapabilities(info.DeviceId);
if (capabilities is not null)
{
ReportCapabilities(info, capabilities, false);
}
}
_eventManager.Publish(new SessionStartedEventArgs(info));
EventHelper.QueueEventIfNotNull(
SessionStarted,
this,
new SessionEventArgs
{
SessionInfo = info
},
_logger);
}
private async ValueTask OnSessionEnded(SessionInfo info)
{
EventHelper.QueueEventIfNotNull(
SessionEnded,
this,
new SessionEventArgs
{
SessionInfo = info
},
_logger);
_eventManager.Publish(new SessionEndedEventArgs(info));
await RemoveFromDirectoryAsync(info).ConfigureAwait(false);
await info.DisposeAsync().ConfigureAwait(false);
}
///
public void UpdateDeviceName(string sessionId, string reportedDeviceName)
{
var session = GetSession(sessionId);
if (session is not null)
{
session.DeviceName = reportedDeviceName;
}
}
///
/// Logs the user activity.
///
/// Type of the client.
/// The app version.
/// The device id.
/// Name of the device.
/// The remote end point.
/// The user.
/// SessionInfo.
public async Task LogSessionActivity(
string appName,
string appVersion,
string deviceId,
string deviceName,
string remoteEndPoint,
User user)
{
CheckDisposed();
ArgumentException.ThrowIfNullOrEmpty(appName);
ArgumentException.ThrowIfNullOrEmpty(appVersion);
ArgumentException.ThrowIfNullOrEmpty(deviceId);
var activityDate = DateTime.UtcNow;
var session = await GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
var lastActivityDate = session.LastActivityDate;
session.LastActivityDate = activityDate;
if (user is not null)
{
var userLastActivityDate = user.LastActivityDate ?? DateTime.MinValue;
if ((activityDate - userLastActivityDate).TotalSeconds > 60)
{
try
{
user.LastActivityDate = activityDate;
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
}
catch (DbUpdateConcurrencyException)
{
_logger.LogDebug("Error updating user's last activity date due to concurrency conflict. This is an expected event.");
}
}
}
if ((activityDate - lastActivityDate).TotalSeconds > 10)
{
SessionActivity?.Invoke(
this,
new SessionEventArgs
{
SessionInfo = session
});
}
QueueDirectoryPublish(session);
return session;
}
///
public async Task OnSessionControllerConnected(SessionInfo session)
{
EventHelper.QueueEventIfNotNull(
SessionControllerConnected,
this,
new SessionEventArgs
{
SessionInfo = session
},
_logger);
// Ownership of the session belongs to whichever instance holds its connection, so this one
// claims it before the connection is used.
_lastDirectoryPublish[session.Id] = Environment.TickCount64;
await PublishToDirectoryAsync(session).ConfigureAwait(false);
}
// Keeps the directory write off the request path: the caller does not wait for Redis, and a
// session reporting playback every few seconds does not write on every report.
private void QueueDirectoryPublish(SessionInfo session)
{
if (!_directoryEnabled || string.IsNullOrEmpty(session.Id))
{
return;
}
var now = Environment.TickCount64;
var throttleMs = Math.Max(1000L, _sessionDirectoryOptions.RefreshIntervalSeconds * 500L);
var scheduled = _lastDirectoryPublish.AddOrUpdate(
session.Id,
now,
(_, last) => now - last >= throttleMs ? now : last);
if (scheduled == now)
{
_ = PublishToDirectoryAsync(session);
}
}
// A playback transition changes what every instance's session list shows, so it is not left to
// the throttle.
private Task PublishToDirectoryNowAsync(SessionInfo session)
{
if (!_directoryEnabled || string.IsNullOrEmpty(session.Id))
{
return Task.CompletedTask;
}
_lastDirectoryPublish[session.Id] = Environment.TickCount64;
return PublishToDirectoryAsync(session);
}
private async Task PublishToDirectoryAsync(SessionInfo session)
{
if (!_directoryEnabled || string.IsNullOrEmpty(session.Id))
{
return;
}
try
{
var connectionEpoch = await GetConnectionEpochAsync(session).ConfigureAwait(false);
_directoryOwned[session.Id] = await _sessionDirectory.PublishAsync(
new SessionDirectoryEntry
{
OwnerPod = _podMessageBus.PodId,
HoldsConnection = connectionEpoch > 0,
Session = ToSessionInfoDto(session)
},
connectionEpoch).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error publishing session {Session} to the directory.", session.Id);
}
}
// Ownership follows the connection, not the last request served: an instance without a live
// controller claims with epoch zero, which never displaces an entry another instance holds. The
// epoch is handed out by the shared store, so the epochs of two instances are ordered by one
// clock rather than by whichever machine's wall clock wrote them.
private async Task GetConnectionEpochAsync(SessionInfo session)
{
if (!session.SessionControllers.Any(i => i.IsSessionActive))
{
_connectionEpochs.TryRemove(session.Id, out _);
return 0;
}
if (_connectionEpochs.TryGetValue(session.Id, out var epoch))
{
return epoch;
}
var allocated = await _sessionDirectory.AllocateConnectionEpochAsync(session.Id).ConfigureAwait(false);
return _connectionEpochs.GetOrAdd(session.Id, allocated);
}
private async ValueTask RemoveFromDirectoryAsync(SessionInfo session)
{
_connectionEpochs.TryRemove(session.Id, out _);
_lastDirectoryPublish.TryRemove(session.Id, out _);
_directoryOwned.TryRemove(session.Id, out _);
if (!_directoryEnabled || string.IsNullOrEmpty(session.Id))
{
return;
}
await _sessionDirectory.RemoveAsync(session.Id, _podMessageBus.PodId).ConfigureAwait(false);
}
private void RefreshSessionDirectory(object state)
{
if (Interlocked.CompareExchange(ref _refreshingDirectory, 1, 0) == 0)
{
_ = RefreshSessionDirectoryAsync();
}
}
// Only the entries this instance can hold are refreshed: republishing a copy of a session another
// instance owns just has the claim refused.
private async Task RefreshSessionDirectoryAsync()
{
try
{
foreach (var session in _activeConnections.Values)
{
if (session.SessionControllers.Any(i => i.IsSessionActive)
|| (_directoryOwned.TryGetValue(session.Id, out var owned) && owned))
{
await PublishToDirectoryAsync(session).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error refreshing the session directory.");
}
finally
{
Interlocked.Exchange(ref _refreshingDirectory, 0);
}
}
private async Task> GetRemoteEntriesAsync(CancellationToken cancellationToken)
{
if (!_directoryEnabled)
{
return Array.Empty();
}
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 GetRemoteSession(string sessionId)
{
if (!_directoryEnabled)
{
return null;
}
var entry = await _sessionDirectory.GetAsync(sessionId).ConfigureAwait(false);
if (entry?.Session is null
|| string.Equals(entry.OwnerPod, _podMessageBus.PodId, StringComparison.Ordinal))
{
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, _logger, entry.OwnerPod, dto.Id, dto.SupportsMediaControl, entry.HoldsConnection));
return session;
}
// What is returned here becomes the sender's answer, so a message this instance could not carry
// out is reported as undelivered instead of being counted by the sender as having arrived.
private Task OnPodMessage(PodMessage message)
{
switch (message.Kind)
{
case RoutedSessionMessage.Kind:
return OnRoutedSessionMessage(message);
case RoutedAdditionalUserChange.Kind:
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:
return OnRoutedPlaybackReport(message);
default:
return Task.FromResult(false);
}
}
private async Task OnRoutedSessionMessage(PodMessage message)
{
var routed = JsonSerializer.Deserialize(message.Payload, JsonDefaults.Options);
if (routed is null)
{
return false;
}
var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, routed.SessionId, StringComparison.Ordinal));
var controllers = session?.SessionControllers.Where(i => i.IsSessionActive).ToList();
if (controllers is null || controllers.Count == 0)
{
_logger.LogWarning(
"A {MessageType} message for session {Session} was routed to this instance, which no longer holds its connection.",
routed.MessageType,
routed.SessionId);
return false;
}
using var data = JsonDocument.Parse(routed.Data);
foreach (var controller in controllers)
{
await controller.SendMessage(routed.MessageType, routed.MessageId, data.RootElement, CancellationToken.None).ConfigureAwait(false);
}
return true;
}
private bool OnRoutedAdditionalUserChange(PodMessage message)
{
var routed = JsonSerializer.Deserialize(message.Payload, JsonDefaults.Options);
var session = routed is null
? null
: Sessions.FirstOrDefault(i => string.Equals(i.Id, routed.SessionId, StringComparison.Ordinal));
if (session is null)
{
return false;
}
if (routed.Add)
{
AttachAdditionalUser(session, routed.UserId, _userManager.GetUserById(routed.UserId)?.Username);
}
else
{
DetachAdditionalUser(session, routed.UserId);
}
QueueDirectoryPublish(session);
return true;
}
private bool OnRoutedNowViewingItem(PodMessage message)
{
var routed = JsonSerializer.Deserialize(message.Payload, JsonDefaults.Options);
var session = routed is null
? null
: Sessions.FirstOrDefault(i => string.Equals(i.Id, routed.SessionId, StringComparison.Ordinal));
if (session is null)
{
return false;
}
SetNowViewingItem(session, routed.ItemId);
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
{
switch (message.Kind)
{
case RoutedPlaybackReport.StartKind:
await OnPlaybackStartCore(Deserialize(message)).ConfigureAwait(false);
return true;
case RoutedPlaybackReport.ProgressKind:
return await OnPlaybackProgressCore(Deserialize(message), false).ConfigureAwait(false);
default:
await OnPlaybackStoppedCore(Deserialize(message)).ConfigureAwait(false);
return true;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "A {Kind} report routed to this instance could not be applied.", message.Kind);
return false;
}
}
private static T Deserialize(PodMessage message)
=> JsonSerializer.Deserialize(message.Payload, JsonDefaults.Options);
// A sessionId-addressed mutation belongs to the instance whose copy of the session everyone else
// is shown. An undeliverable route falls back to handling it here, which is what a deployment
// without a directory does anyway.
private async Task TryRouteToOwnerAsync(string sessionId, string kind, object payload, CancellationToken cancellationToken)
{
if (!_directoryEnabled || string.IsNullOrEmpty(sessionId))
{
return false;
}
try
{
var entry = await _sessionDirectory.GetAsync(sessionId, cancellationToken).ConfigureAwait(false);
if (entry is null || string.Equals(entry.OwnerPod, _podMessageBus.PodId, StringComparison.Ordinal))
{
return false;
}
var routed = await _podMessageBus.RequestAsync(
entry.OwnerPod,
new PodMessage
{
Kind = kind,
Payload = JsonSerializer.Serialize(payload, JsonDefaults.Options)
},
cancellationToken).ConfigureAwait(false);
if (!routed)
{
// 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;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Could not reach the owner of session {Session}; the {Kind} report is applied here instead.", sessionId, kind);
return false;
}
}
///
public async Task CloseIfNeededAsync(SessionInfo session)
{
if (!session.SessionControllers.Any(i => i.IsSessionActive))
{
var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
_activeConnections.TryRemove(key, out _);
if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId))
{
await CloseLiveStreamIfNeededAsync(session.PlayState.LiveStreamId, session.Id).ConfigureAwait(false);
}
await OnSessionEnded(session).ConfigureAwait(false);
}
}
///
public async Task CloseLiveStreamIfNeededAsync(string liveStreamId, string sessionIdOrPlaySessionId)
{
bool liveStreamNeedsToBeClosed = false;
if (_activeLiveStreamSessions.TryGetValue(liveStreamId, out var activeSessionMappings))
{
if (activeSessionMappings.TryRemove(sessionIdOrPlaySessionId, out var correspondingId))
{
if (!string.IsNullOrEmpty(correspondingId))
{
activeSessionMappings.TryRemove(correspondingId, out _);
}
liveStreamNeedsToBeClosed = true;
}
if (activeSessionMappings.IsEmpty)
{
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
}
}
else
{
liveStreamNeedsToBeClosed = true;
}
if (liveStreamNeedsToBeClosed)
{
try
{
await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error closing live stream");
}
}
}
///
public async ValueTask ReportSessionEnded(string sessionId)
{
CheckDisposed();
var session = GetSession(sessionId, false);
if (session is not null)
{
var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
_activeConnections.TryRemove(key, out _);
await OnSessionEnded(session).ConfigureAwait(false);
}
}
private Task GetMediaSource(BaseItem item, string mediaSourceId, string liveStreamId)
{
return _mediaSourceManager.GetMediaSource(item, mediaSourceId, liveStreamId, false, CancellationToken.None);
}
///
/// Updates the now playing item id.
///
/// Task.
private async Task UpdateNowPlayingItem(SessionInfo session, PlaybackProgressInfo info, BaseItem libraryItem, bool updateLastCheckInTime)
{
if (session is null)
{
return;
}
if (string.IsNullOrEmpty(info.MediaSourceId))
{
info.MediaSourceId = info.ItemId.ToString("N", CultureInfo.InvariantCulture);
}
if (!info.ItemId.IsEmpty() && info.Item is null && libraryItem is not null)
{
var current = session.NowPlayingItem;
if (current is null || !info.ItemId.Equals(current.Id))
{
var runtimeTicks = libraryItem.RunTimeTicks;
MediaSourceInfo mediaSource = null;
if (libraryItem is IHasMediaSources)
{
mediaSource = await GetMediaSource(libraryItem, info.MediaSourceId, info.LiveStreamId).ConfigureAwait(false);
if (mediaSource is not null)
{
runtimeTicks = mediaSource.RunTimeTicks;
}
}
info.Item = GetItemInfo(libraryItem, mediaSource);
info.Item.RunTimeTicks = runtimeTicks;
}
else
{
info.Item = current;
}
}
session.NowPlayingItem = info.Item;
session.LastActivityDate = DateTime.UtcNow;
if (updateLastCheckInTime)
{
session.LastPlaybackCheckIn = DateTime.UtcNow;
}
if (info.IsPaused && session.LastPausedDate is null)
{
session.LastPausedDate = DateTime.UtcNow;
}
else if (!info.IsPaused)
{
session.LastPausedDate = null;
}
session.PlayState.IsPaused = info.IsPaused;
session.PlayState.PositionTicks = info.PositionTicks;
session.PlayState.MediaSourceId = info.MediaSourceId;
session.PlayState.LiveStreamId = info.LiveStreamId;
session.PlayState.CanSeek = info.CanSeek;
session.PlayState.IsMuted = info.IsMuted;
session.PlayState.VolumeLevel = info.VolumeLevel;
session.PlayState.AudioStreamIndex = info.AudioStreamIndex;
session.PlayState.SubtitleStreamIndex = info.SubtitleStreamIndex;
session.PlayState.PlayMethod = info.PlayMethod;
session.PlayState.RepeatMode = info.RepeatMode;
session.PlayState.PlaybackOrder = info.PlaybackOrder;
session.PlaylistItemId = info.PlaylistItemId;
}
///
/// Removes the now playing item id.
///
/// The session.
private void RemoveNowPlayingItem(SessionInfo session)
{
session.NowPlayingItem = null;
session.FullNowPlayingItem = null;
session.PlayState = new PlayerStateInfo();
if (!string.IsNullOrEmpty(session.DeviceId))
{
ClearTranscodingInfo(session.DeviceId);
}
}
// The user is part of the key because the client name and the device id are taken from the
// request headers and are not bound to the access token. Without it, any authenticated user
// could claim another user's client/device pair and take over their session.
private static string GetSessionKey(string appName, string deviceId, Guid userId)
=> appName + deviceId + userId.ToString("N", CultureInfo.InvariantCulture);
///
/// Gets the connection.
///
/// Type of the client.
/// The app version.
/// The device id.
/// Name of the device.
/// The remote end point.
/// The user.
/// SessionInfo.
private async Task GetSessionInfo(
string appName,
string appVersion,
string deviceId,
string deviceName,
string remoteEndPoint,
User user)
{
CheckDisposed();
ArgumentException.ThrowIfNullOrEmpty(deviceId);
var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty);
SessionInfo newSession = await CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
if (ReferenceEquals(newSession, sessionInfo))
{
OnSessionStarted(newSession);
}
sessionInfo.UserId = user?.Id ?? Guid.Empty;
sessionInfo.UserName = user?.Username;
sessionInfo.UserPrimaryImageTag = user?.ProfileImage is null ? null : GetImageCacheTag(user);
sessionInfo.RemoteEndPoint = remoteEndPoint;
sessionInfo.Client = appName;
if (!sessionInfo.HasCustomDeviceName || string.IsNullOrEmpty(sessionInfo.DeviceName))
{
sessionInfo.DeviceName = deviceName;
}
sessionInfo.ApplicationVersion = appVersion;
if (user is null)
{
sessionInfo.AdditionalUsers = Array.Empty();
}
return sessionInfo;
}
private async Task CreateSessionInfo(
string key,
string appName,
string appVersion,
string deviceId,
string deviceName,
string remoteEndPoint,
User user)
{
var sessionInfo = new SessionInfo(this, _logger)
{
Client = appName,
DeviceId = deviceId,
ApplicationVersion = appVersion,
Id = key.GetMD5().ToString("N", CultureInfo.InvariantCulture),
ServerId = _appHost.SystemId
};
var username = user?.Username;
sessionInfo.UserId = user?.Id ?? Guid.Empty;
sessionInfo.UserName = username;
sessionInfo.UserPrimaryImageTag = user?.ProfileImage is null ? null : GetImageCacheTag(user);
sessionInfo.RemoteEndPoint = remoteEndPoint;
if (string.IsNullOrEmpty(deviceName))
{
deviceName = "Network Device";
}
var deviceOptions = await _deviceManager.GetDeviceOptions(deviceId).ConfigureAwait(false) ?? new()
{
DeviceId = deviceId
};
if (string.IsNullOrEmpty(deviceOptions.CustomName))
{
sessionInfo.DeviceName = deviceName;
}
else
{
sessionInfo.DeviceName = deviceOptions.CustomName;
sessionInfo.HasCustomDeviceName = true;
}
return sessionInfo;
}
private List GetUsers(SessionInfo session)
{
var users = new List();
if (session.UserId.IsEmpty())
{
return users;
}
var user = _userManager.GetUserById(session.UserId);
if (user is null)
{
throw new InvalidOperationException("User not found");
}
users.Add(user);
users.AddRange(session.AdditionalUsers
.Select(i => _userManager.GetUserById(i.UserId))
.Where(i => i is not null));
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));
if (_config.Configuration.InactiveSessionThreshold > 0)
{
_inactiveTimer ??= new Timer(CheckForInactiveSteams, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
else
{
StopInactiveCheckTimer();
}
}
private void StopIdleCheckTimer()
{
if (_idleTimer is not null)
{
_idleTimer.Dispose();
_idleTimer = null;
}
}
private void StopInactiveCheckTimer()
{
if (_inactiveTimer is not null)
{
_inactiveTimer.Dispose();
_inactiveTimer = null;
}
}
private async void CheckForIdlePlayback(object state)
{
var playingSessions = Sessions.Where(i => i.NowPlayingItem is not null)
.ToList();
if (playingSessions.Count > 0)
{
var idle = playingSessions
.Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5);
foreach (var session in idle)
{
if (!await OwnsSessionAsync(session).ConfigureAwait(false))
{
continue;
}
_logger.LogDebug("Session {0} has gone idle while playing", session.Id);
try
{
await OnPlaybackStopped(new PlaybackStopInfo
{
Item = session.NowPlayingItem,
ItemId = session.NowPlayingItem is null ? Guid.Empty : session.NowPlayingItem.Id,
SessionId = session.Id,
MediaSourceId = session.PlayState?.MediaSourceId,
PositionTicks = session.LastPlaybackCheckInPositionTicks
}).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error calling OnPlaybackStopped");
}
}
}
else
{
StopIdleCheckTimer();
}
}
private async void CheckForInactiveSteams(object state)
{
var inactiveSessions = Sessions.Where(i =>
i.NowPlayingItem is not null
&& i.PlayState.IsPaused
&& (DateTime.UtcNow - i.LastPausedDate).Value.TotalMinutes > _config.Configuration.InactiveSessionThreshold);
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
{
await SendPlaystateCommand(
session.Id,
session.Id,
new PlaystateRequest()
{
Command = PlaystateCommand.Stop,
ControllingUserId = session.UserId.ToString(),
SeekPositionTicks = session.PlayState?.PositionTicks
},
CancellationToken.None).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error calling SendPlaystateCommand for stopping inactive session {Session}.", session.Id);
}
}
bool playingSessions = Sessions.Any(i => i.NowPlayingItem is not null);
if (!playingSessions)
{
StopInactiveCheckTimer();
}
}
private BaseItem GetNowPlayingItem(SessionInfo session, Guid itemId)
{
if (session is null)
{
return null;
}
var item = session.FullNowPlayingItem;
if (item is not null && item.Id.Equals(itemId))
{
return item;
}
item = _libraryManager.GetItemById(itemId);
session.FullNowPlayingItem = item;
return item;
}
///
/// Resolves the item whose user data (playback position, played status) should be updated
/// for a playback report. When an alternate version is played the client reports the displayed
/// item as ItemId and the played version as MediaSourceId.
///
/// The now playing (displayed) item.
/// The reported media source id.
/// The item to track progress against.
private BaseItem GetProgressItem(BaseItem libraryItem, string mediaSourceId)
{
if (libraryItem is Video libraryVideo
&& !string.IsNullOrEmpty(mediaSourceId)
&& Guid.TryParse(mediaSourceId, out var mediaSourceItemId)
&& !mediaSourceItemId.Equals(libraryVideo.Id))
{
var versionItem = libraryVideo.GetAlternateVersion(mediaSourceItemId);
if (versionItem is not null)
{
return versionItem;
}
}
return libraryItem;
}
///
/// Used to report that playback has started for an item.
///
/// The info.
/// Task.
/// info is null.
public async Task OnPlaybackStart(PlaybackStartInfo info)
{
CheckDisposed();
ArgumentNullException.ThrowIfNull(info);
if (await TryRouteToOwnerAsync(info.SessionId, RoutedPlaybackReport.StartKind, info, CancellationToken.None).ConfigureAwait(false))
{
return;
}
await OnPlaybackStartCore(info).ConfigureAwait(false);
}
private async Task OnPlaybackStartCore(PlaybackStartInfo info)
{
var session = GetSession(info.SessionId);
var libraryItem = info.ItemId.IsEmpty()
? null
: GetNowPlayingItem(session, info.ItemId);
await UpdateNowPlayingItem(session, info, libraryItem, true).ConfigureAwait(false);
if (!string.IsNullOrEmpty(session.DeviceId) && info.PlayMethod != PlayMethod.Transcode)
{
ClearTranscodingInfo(session.DeviceId);
}
session.StartAutomaticProgress(info);
var users = GetUsers(session);
if (libraryItem is not null)
{
var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
OnPlaybackStart(user, progressItem);
}
}
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
}
var eventArgs = new PlaybackStartEventArgs
{
Item = libraryItem,
Users = users,
MediaSourceId = info.MediaSourceId,
MediaInfo = info.Item,
DeviceName = session.DeviceName,
ClientName = session.Client,
DeviceId = session.DeviceId,
Session = session,
PlaybackPositionTicks = info.PositionTicks,
PlaySessionId = info.PlaySessionId
};
if (info.Item is not null)
{
_logger.LogInformation(
"User {0} started playback of '{1}' ({2} {3})",
session.UserName,
info.Item.Name,
session.Client,
session.ApplicationVersion);
}
await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
// Nothing to save here
// Fire events to inform plugins
EventHelper.QueueEventIfNotNull(
PlaybackStart,
this,
eventArgs,
_logger);
StartCheckTimers();
await PublishToDirectoryNowAsync(session).ConfigureAwait(false);
}
///
/// Called when [playback start].
///
/// The user object.
/// The item.
private void OnPlaybackStart(User user, BaseItem item)
{
var data = _userDataManager.GetUserData(user, item);
data.PlayCount++;
data.LastPlayedDate = DateTime.UtcNow;
if (item.SupportsPlayedStatus && !item.SupportsPositionTicksResume)
{
data.Played = true;
}
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackStart, CancellationToken.None);
}
///
public Task OnPlaybackProgress(PlaybackProgressInfo info)
{
return OnPlaybackProgress(info, false);
}
private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
{
var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(liveStreamId, _ => new ConcurrentDictionary());
if (!string.IsNullOrEmpty(playSessionId))
{
if (!activeSessionMappings.TryGetValue(sessionId, out var currentPlaySessionId) || currentPlaySessionId != playSessionId)
{
if (!string.IsNullOrEmpty(currentPlaySessionId))
{
activeSessionMappings.TryRemove(currentPlaySessionId, out _);
}
activeSessionMappings[sessionId] = playSessionId;
activeSessionMappings[playSessionId] = sessionId;
}
}
else
{
if (!activeSessionMappings.TryGetValue(sessionId, out _))
{
activeSessionMappings[sessionId] = string.Empty;
}
}
}
///
/// Used to report playback progress for an item.
///
/// The playback progress info.
/// Whether this is an automated update.
/// Task.
public async Task OnPlaybackProgress(PlaybackProgressInfo info, bool isAutomated)
{
CheckDisposed();
ArgumentNullException.ThrowIfNull(info);
// An automated report is generated from the copy of the session this instance already holds,
// so it is never the one that belongs somewhere else.
if (!isAutomated
&& await TryRouteToOwnerAsync(info.SessionId, RoutedPlaybackReport.ProgressKind, info, CancellationToken.None).ConfigureAwait(false))
{
return;
}
await OnPlaybackProgressCore(info, isAutomated).ConfigureAwait(false);
}
private async Task OnPlaybackProgressCore(PlaybackProgressInfo info, bool isAutomated)
{
var session = GetSession(info.SessionId, false);
if (session is null)
{
return false;
}
var libraryItem = info.ItemId.IsEmpty()
? null
: GetNowPlayingItem(session, info.ItemId);
await UpdateNowPlayingItem(session, info, libraryItem, !isAutomated).ConfigureAwait(false);
if (!string.IsNullOrEmpty(session.DeviceId) && info.PlayMethod != PlayMethod.Transcode)
{
ClearTranscodingInfo(session.DeviceId);
}
var users = GetUsers(session);
// only update saved user data on actual check-ins, not automated ones
if (libraryItem is not null && !isAutomated)
{
var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
OnPlaybackProgress(user, progressItem, info);
}
}
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
}
var eventArgs = new PlaybackProgressEventArgs
{
Item = libraryItem,
Users = users,
PlaybackPositionTicks = session.PlayState.PositionTicks,
MediaSourceId = session.PlayState.MediaSourceId,
MediaInfo = info.Item,
DeviceName = session.DeviceName,
ClientName = session.Client,
DeviceId = session.DeviceId,
IsPaused = info.IsPaused,
PlaySessionId = info.PlaySessionId,
IsAutomated = isAutomated,
Session = session
};
await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
PlaybackProgress?.Invoke(this, eventArgs);
if (!isAutomated)
{
session.StartAutomaticProgress(info);
}
StartCheckTimers();
QueueDirectoryPublish(session);
return true;
}
private void OnPlaybackProgress(User user, BaseItem item, PlaybackProgressInfo info)
{
var data = _userDataManager.GetUserData(user, item);
var positionTicks = info.PositionTicks;
var changed = false;
if (positionTicks.HasValue)
{
_userDataManager.UpdatePlayState(item, data, positionTicks.Value);
changed = true;
}
var tracksChanged = UpdatePlaybackSettings(user, info, data);
if (tracksChanged)
{
changed = true;
}
if (changed)
{
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None);
// A completed version marks every alternate version played and clears their resume points, so the
// whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
// only persist while nothing has been completed yet.)
if (data.Played == true && item is Video playedVideo)
{
playedVideo.PropagatePlayedState(user, true);
}
}
if ((!user.RememberAudioSelections && info.AudioStreamIndex.HasValue)
|| (!user.RememberSubtitleSelections && info.SubtitleStreamIndex.HasValue))
{
_userDataManager.ResetPlaybackStreamSelections(user, item);
}
}
private static bool UpdatePlaybackSettings(User user, PlaybackProgressInfo info, UserItemData data)
{
var changed = false;
if (user.RememberAudioSelections)
{
if (info.AudioStreamIndex.HasValue && data.AudioStreamIndex != info.AudioStreamIndex)
{
data.AudioStreamIndex = info.AudioStreamIndex;
changed = true;
}
}
else
{
if (data.AudioStreamIndex.HasValue)
{
data.AudioStreamIndex = null;
changed = true;
}
}
if (user.RememberSubtitleSelections)
{
if (info.SubtitleStreamIndex.HasValue && data.SubtitleStreamIndex != info.SubtitleStreamIndex)
{
data.SubtitleStreamIndex = info.SubtitleStreamIndex;
changed = true;
}
}
else
{
if (data.SubtitleStreamIndex.HasValue)
{
data.SubtitleStreamIndex = null;
changed = true;
}
}
return changed;
}
///
/// Used to report that playback has ended for an item.
///
/// The info.
/// Task.
/// info is null.
/// info.PositionTicks is null or negative.
public async Task OnPlaybackStopped(PlaybackStopInfo info)
{
CheckDisposed();
ArgumentNullException.ThrowIfNull(info);
if (await TryRouteToOwnerAsync(info.SessionId, RoutedPlaybackReport.StoppedKind, info, CancellationToken.None).ConfigureAwait(false))
{
return;
}
await OnPlaybackStoppedCore(info).ConfigureAwait(false);
}
private async Task OnPlaybackStoppedCore(PlaybackStopInfo info)
{
var session = GetSession(info.SessionId);
session.StopAutomaticProgress();
if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0)
{
// Ensure live stream is cleaned up before throwing, to prevent tuner
// resource leaks when stalled clients report a negative PositionTicks.
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
await CloseLiveStreamIfNeededAsync(info.LiveStreamId, session.Id).ConfigureAwait(false);
}
throw new ArgumentOutOfRangeException(nameof(info), "The PlaybackStopInfo's PositionTicks was negative.");
}
var libraryItem = info.ItemId.IsEmpty()
? null
: GetNowPlayingItem(session, info.ItemId);
// Normalize
if (string.IsNullOrEmpty(info.MediaSourceId))
{
info.MediaSourceId = info.ItemId.ToString("N", CultureInfo.InvariantCulture);
}
if (!info.ItemId.IsEmpty() && info.Item is null && libraryItem is not null)
{
var current = session.NowPlayingItem;
if (current is null || !info.ItemId.Equals(current.Id))
{
MediaSourceInfo mediaSource = null;
if (libraryItem is IHasMediaSources)
{
mediaSource = await GetMediaSource(libraryItem, info.MediaSourceId, info.LiveStreamId).ConfigureAwait(false);
}
info.Item = GetItemInfo(libraryItem, mediaSource);
}
else
{
info.Item = current;
}
}
if (info.Item is not null)
{
var msString = info.PositionTicks.HasValue ? (info.PositionTicks.Value / 10000).ToString(CultureInfo.InvariantCulture) : "unknown";
_logger.LogInformation(
"User {0} stopped playback of '{1}' at {2}ms ({3} {4})",
session.UserName,
info.Item.Name,
msString,
session.Client,
session.ApplicationVersion);
}
if (info.NowPlayingQueue is not null)
{
session.NowPlayingQueue = info.NowPlayingQueue;
}
session.PlaylistItemId = info.PlaylistItemId;
RemoveNowPlayingItem(session);
var users = GetUsers(session);
var playedToCompletion = false;
if (libraryItem is not null)
{
var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
playedToCompletion = OnPlaybackStopped(user, progressItem, info.PositionTicks, info.Failed);
}
}
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
await CloseLiveStreamIfNeededAsync(info.LiveStreamId, session.Id).ConfigureAwait(false);
}
var eventArgs = new PlaybackStopEventArgs
{
Item = libraryItem,
Users = users,
PlaybackPositionTicks = info.PositionTicks,
PlayedToCompletion = playedToCompletion,
MediaSourceId = info.MediaSourceId,
MediaInfo = info.Item,
DeviceName = session.DeviceName,
ClientName = session.Client,
DeviceId = session.DeviceId,
Session = session,
PlaySessionId = info.PlaySessionId
};
await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
EventHelper.QueueEventIfNotNull(PlaybackStopped, this, eventArgs, _logger);
await PublishToDirectoryNowAsync(session).ConfigureAwait(false);
}
private bool OnPlaybackStopped(User user, BaseItem item, long? positionTicks, bool playbackFailed)
{
if (playbackFailed)
{
return false;
}
var data = _userDataManager.GetUserData(user, item);
bool playedToCompletion;
if (positionTicks.HasValue)
{
playedToCompletion = _userDataManager.UpdatePlayState(item, data, positionTicks.Value);
}
else
{
// If the client isn't able to report this, then we'll just have to make an assumption
data.PlayCount++;
data.Played = item.SupportsPlayedStatus;
data.PlaybackPositionTicks = 0;
playedToCompletion = true;
}
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None);
// A completed version marks every alternate version played and clears their resume points, so the
// whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
// only persist while nothing has been completed yet.)
if (data.Played == true && item is Video playedVideo)
{
playedVideo.PropagatePlayedState(user, true);
}
return playedToCompletion;
}
///
/// Gets the session.
///
/// The session identifier.
/// if set to true [throw on missing].
/// SessionInfo.
///
/// No session with an Id equal to sessionId was found
/// and throwOnMissing is true.
///
private SessionInfo GetSession(string sessionId, bool throwOnMissing = true)
{
var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId, StringComparison.Ordinal));
if (session is null && throwOnMissing)
{
throw new ResourceNotFoundException(
string.Format(CultureInfo.InvariantCulture, "Session {0} not found.", sessionId));
}
return session;
}
// Resolves the session a message has to be written to. A local SessionInfo without a live
// controller is a copy left behind by a request this instance happened to serve, not the
// connection, so it is never the answer: either the owner named by the directory can take the
// message or nothing can, and the caller is told so rather than handed a copy that silently
// swallows it. A directory that cannot be read raises rather than reading as "no such session".
private async Task GetSessionToRemoteControl(string sessionId)
{
var local = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId, StringComparison.Ordinal));
if (local is not null && local.SessionControllers.Any(i => i.IsSessionActive))
{
return local;
}
return await GetRemoteSession(sessionId).ConfigureAwait(false)
?? throw new ResourceNotFoundException(
string.Format(CultureInfo.InvariantCulture, "No instance holds a connection to session {0}.", sessionId));
}
// Resolves a session to authorize against or to change server-side state on. Nothing is written to
// a connection here, so a copy without one still answers the question.
private async Task GetSessionForControl(string sessionId)
{
var local = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId, StringComparison.Ordinal));
return local
?? await GetRemoteSession(sessionId).ConfigureAwait(false)
?? throw new ResourceNotFoundException(
string.Format(CultureInfo.InvariantCulture, "Session {0} not found.", sessionId));
}
///
public SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
{
return new SessionInfoDto
{
PlayState = sessionInfo.PlayState,
AdditionalUsers = sessionInfo.AdditionalUsers,
Capabilities = _deviceManager.ToClientCapabilitiesDto(sessionInfo.Capabilities),
RemoteEndPoint = sessionInfo.RemoteEndPoint,
PlayableMediaTypes = sessionInfo.PlayableMediaTypes,
Id = sessionInfo.Id,
UserId = sessionInfo.UserId,
UserName = sessionInfo.UserName,
Client = sessionInfo.Client,
LastActivityDate = sessionInfo.LastActivityDate,
LastPlaybackCheckIn = sessionInfo.LastPlaybackCheckIn,
LastPausedDate = sessionInfo.LastPausedDate,
DeviceName = sessionInfo.DeviceName,
DeviceType = sessionInfo.DeviceType,
NowPlayingItem = sessionInfo.NowPlayingItem,
NowViewingItem = sessionInfo.NowViewingItem,
DeviceId = sessionInfo.DeviceId,
ApplicationVersion = sessionInfo.ApplicationVersion,
TranscodingInfo = sessionInfo.TranscodingInfo,
IsActive = sessionInfo.IsActive,
SupportsMediaControl = sessionInfo.SupportsMediaControl,
SupportsRemoteControl = sessionInfo.SupportsRemoteControl,
NowPlayingQueue = sessionInfo.NowPlayingQueue,
HasCustomDeviceName = sessionInfo.HasCustomDeviceName,
PlaylistItemId = sessionInfo.PlaylistItemId,
ServerId = sessionInfo.ServerId,
UserPrimaryImageTag = sessionInfo.UserPrimaryImageTag,
SupportedCommands = sessionInfo.SupportedCommands
};
}
///
public Task SendMessageCommand(string controllingSessionId, string sessionId, MessageCommand command, CancellationToken cancellationToken)
{
CheckDisposed();
var generalCommand = new GeneralCommand
{
Name = GeneralCommandType.DisplayMessage
};
generalCommand.Arguments["Header"] = command.Header;
generalCommand.Arguments["Text"] = command.Text;
if (command.TimeoutMs.HasValue)
{
generalCommand.Arguments["TimeoutMs"] = command.TimeoutMs.Value.ToString(CultureInfo.InvariantCulture);
}
return SendGeneralCommand(controllingSessionId, sessionId, generalCommand, cancellationToken);
}
///
public async Task SendGeneralCommand(string controllingSessionId, string sessionId, GeneralCommand command, CancellationToken cancellationToken)
{
CheckDisposed();
var session = await GetSessionToRemoteControl(sessionId).ConfigureAwait(false);
if (!string.IsNullOrEmpty(controllingSessionId))
{
var controllingSession = await GetSessionForControl(controllingSessionId).ConfigureAwait(false);
AssertCanControl(session, controllingSession);
}
await SendMessageToSession(session, SessionMessageType.GeneralCommand, command, cancellationToken).ConfigureAwait(false);
}
private static async Task SendMessageToSession(SessionInfo session, SessionMessageType name, T data, CancellationToken cancellationToken)
{
var controllers = session.SessionControllers.Where(i => i.IsSessionActive).ToList();
if (controllers.Count == 0)
{
throw new ResourceNotFoundException(
string.Format(CultureInfo.InvariantCulture, "No instance holds a connection to session {0}.", session.Id));
}
var messageId = Guid.NewGuid();
foreach (var controller in controllers)
{
await controller.SendMessage(name, messageId, data, cancellationToken).ConfigureAwait(false);
}
}
private static Task SendMessageToSessions(IEnumerable sessions, SessionMessageType name, T data, CancellationToken cancellationToken)
{
IEnumerable GetTasks()
{
var messageId = Guid.NewGuid();
foreach (var session in sessions)
{
var controllers = session.SessionControllers;
foreach (var controller in controllers)
{
yield return controller.SendMessage(name, messageId, data, cancellationToken);
}
}
}
return Task.WhenAll(GetTasks());
}
///
public async Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken)
{
CheckDisposed();
var session = await GetSessionToRemoteControl(sessionId).ConfigureAwait(false);
var user = session.UserId.IsEmpty() ? null : _userManager.GetUserById(session.UserId);
List items;
if (command.PlayCommand == PlayCommand.PlayInstantMix)
{
items = command.ItemIds.SelectMany(i => TranslateItemForInstantMix(i, user))
.ToList();
command.PlayCommand = PlayCommand.PlayNow;
}
else
{
var list = new List();
foreach (var itemId in command.ItemIds)
{
var subItems = TranslateItemForPlayback(itemId, user);
list.AddRange(subItems);
}
items = list;
}
if (command.PlayCommand == PlayCommand.PlayShuffle)
{
items.Shuffle();
command.PlayCommand = PlayCommand.PlayNow;
}
command.ItemIds = items.Select(i => i.Id).ToArray();
if (user is not null)
{
if (items.Any(i => i.GetPlayAccess(user) != PlayAccess.Full))
{
throw new ArgumentException(
string.Format(CultureInfo.InvariantCulture, "{0} is not allowed to play media.", user.Username));
}
}
if (user is not null
&& command.ItemIds.Length == 1
&& user.EnableNextEpisodeAutoPlay
&& _libraryManager.GetItemById(command.ItemIds[0]) is Episode episode)
{
var series = episode.Series;
if (series is not null)
{
var episodes = series.GetEpisodes(
user,
new DtoOptions(false)
{
EnableImages = false
},
user.DisplayMissingEpisodes)
.Where(i => !i.IsVirtualItem)
.SkipWhile(i => !i.Id.Equals(episode.Id))
.ToList();
if (episodes.Count > 0)
{
command.ItemIds = episodes.Select(i => i.Id).ToArray();
}
}
}
if (!string.IsNullOrEmpty(controllingSessionId))
{
var controllingSession = await GetSessionForControl(controllingSessionId).ConfigureAwait(false);
AssertCanControl(session, controllingSession);
if (!controllingSession.UserId.IsEmpty())
{
command.ControllingUserId = controllingSession.UserId;
}
}
await SendMessageToSession(session, SessionMessageType.Play, command, cancellationToken).ConfigureAwait(false);
}
///
public async Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken)
{
CheckDisposed();
// SyncPlay group membership is instance-local, so a session listed by another instance is not
// reachable from here. It is skipped rather than reported as missing.
var session = GetConnectedSession(sessionId);
if (session is null)
{
_logger.LogDebug("SyncPlay command for session {Session} dropped; this instance does not hold its connection.", sessionId);
return;
}
await SendMessageToSession(session, SessionMessageType.SyncPlayCommand, command, cancellationToken).ConfigureAwait(false);
}
///
public async Task SendSyncPlayGroupUpdate(string sessionId, GroupUpdate command, CancellationToken cancellationToken)
{
CheckDisposed();
var session = GetConnectedSession(sessionId);
if (session is null)
{
_logger.LogDebug("SyncPlay group update for session {Session} dropped; this instance does not hold its connection.", sessionId);
return;
}
await SendMessageToSession(session, SessionMessageType.SyncPlayGroupUpdate, command, cancellationToken).ConfigureAwait(false);
}
// Both instances keep a copy of a session whose requests they have served, so holding a copy is
// not holding the connection.
private SessionInfo GetConnectedSession(string sessionId)
{
var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId, StringComparison.Ordinal));
return session?.SessionControllers.Any(i => i.IsSessionActive) == true ? session : null;
}
private IEnumerable TranslateItemForPlayback(Guid id, User user)
{
var item = _libraryManager.GetItemById(id);
if (item is null)
{
_logger.LogError("A nonexistent item Id {0} was passed into TranslateItemForPlayback", id);
return Array.Empty();
}
if (item is IItemByName byName)
{
return byName.GetTaggedItems(new InternalItemsQuery(user)
{
IsFolder = false,
Recursive = true,
DtoOptions = new DtoOptions(false)
{
EnableImages = false,
Fields = new[]
{
ItemFields.SortName
}
},
IsVirtualItem = false,
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }
});
}
if (item.IsFolder)
{
var folder = (Folder)item;
return folder.GetItemList(new InternalItemsQuery(user)
{
Recursive = true,
IsFolder = false,
DtoOptions = new DtoOptions(false)
{
EnableImages = false,
Fields = new ItemFields[]
{
ItemFields.SortName
}
},
IsVirtualItem = false,
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }
});
}
return new[] { item };
}
private List TranslateItemForInstantMix(Guid id, User user)
{
var item = _libraryManager.GetItemById(id);
if (item is null)
{
_logger.LogError("A nonexistent item Id {0} was passed into TranslateItemForInstantMix", id);
return new List();
}
return _musicManager.GetInstantMixFromItem(item, user, new DtoOptions(false) { EnableImages = false }).ToList();
}
///
public Task SendBrowseCommand(string controllingSessionId, string sessionId, BrowseRequest command, CancellationToken cancellationToken)
{
var generalCommand = new GeneralCommand
{
Name = GeneralCommandType.DisplayContent,
Arguments =
{
["ItemId"] = command.ItemId,
["ItemName"] = command.ItemName,
["ItemType"] = command.ItemType.ToString()
}
};
return SendGeneralCommand(controllingSessionId, sessionId, generalCommand, cancellationToken);
}
///
public async Task SendPlaystateCommand(string controllingSessionId, string sessionId, PlaystateRequest command, CancellationToken cancellationToken)
{
CheckDisposed();
var session = await GetSessionToRemoteControl(sessionId).ConfigureAwait(false);
if (!string.IsNullOrEmpty(controllingSessionId))
{
var controllingSession = await GetSessionForControl(controllingSessionId).ConfigureAwait(false);
AssertCanControl(session, controllingSession);
if (!controllingSession.UserId.IsEmpty())
{
command.ControllingUserId = controllingSession.UserId.ToString("N", CultureInfo.InvariantCulture);
}
}
await SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken).ConfigureAwait(false);
}
private void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(controllingSession);
var controllingUserId = controllingSession.UserId;
// Controlling a session is always allowed when:
// - the caller has no associated user (an API key, which is a privileged context),
// - the target session is public (has no owning user), or
// - the caller's user is associated with the target session.
// Controlling a session owned by a different user requires the
// EnableRemoteControlOfOtherUsers permission.
if (controllingUserId.IsEmpty()
|| session.UserId.IsEmpty()
|| session.ContainsUser(controllingUserId))
{
return;
}
var controllingUser = _userManager.GetUserById(controllingUserId);
if (controllingUser is null
|| !controllingUser.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
{
throw new SecurityException("The current user does not have permission to remote control other users.");
}
}
private void AssertCanAttachUser(SessionInfo controllingSession, Guid userId)
{
var controllingUserId = controllingSession.UserId;
// Playback reported by a session is also written to the user data of its additional users,
// so attaching anyone but the calling user requires administrative privileges.
if (controllingUserId.IsEmpty() || controllingUserId.Equals(userId))
{
return;
}
var controllingUser = _userManager.GetUserById(controllingUserId);
if (controllingUser is null
|| !controllingUser.HasPermission(PermissionKind.IsAdministrator))
{
throw new SecurityException("The current user does not have permission to attach another user to a session.");
}
}
///
/// Sends the restart required message.
///
/// The cancellation token.
/// Task.
public Task SendRestartRequiredNotification(CancellationToken cancellationToken)
{
CheckDisposed();
return SendMessageToSessions(Sessions, SessionMessageType.RestartRequired, string.Empty, cancellationToken);
}
///
/// Adds the additional user.
///
/// The controlling session identifier.
/// The session identifier.
/// The user identifier.
/// A task representing the operation.
/// The controlling user is not allowed to attach the user to the session.
/// The requested user is already the primary user of the session.
public async Task AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
{
CheckDisposed();
var session = await GetSessionForControl(sessionId).ConfigureAwait(false);
if (!string.IsNullOrEmpty(controllingSessionId))
{
var controllingSession = await GetSessionForControl(controllingSessionId).ConfigureAwait(false);
AssertCanControl(session, controllingSession);
AssertCanAttachUser(controllingSession, userId);
}
if (session.UserId.Equals(userId))
{
throw new ArgumentException("The requested user is already the primary user of the session.");
}
var user = _userManager.GetUserById(userId)
?? throw new ArgumentException("The requested user does not exist.");
await RouteAdditionalUserChange(sessionId, userId, true).ConfigureAwait(false);
var local = GetSession(sessionId, false);
if (local is not null)
{
AttachAdditionalUser(local, userId, user.Username);
QueueDirectoryPublish(local);
}
}
///
/// Removes the additional user.
///
/// The controlling session identifier.
/// The session identifier.
/// The user identifier.
/// A task representing the operation.
/// The controlling user is not allowed to control the session.
/// The requested user is already the primary user of the session.
public async Task RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
{
CheckDisposed();
var session = await GetSessionForControl(sessionId).ConfigureAwait(false);
if (!string.IsNullOrEmpty(controllingSessionId))
{
AssertCanControl(session, await GetSessionForControl(controllingSessionId).ConfigureAwait(false));
}
if (session.UserId.Equals(userId))
{
throw new ArgumentException("The requested user is already the primary user of the session.");
}
await RouteAdditionalUserChange(sessionId, userId, false).ConfigureAwait(false);
var local = GetSession(sessionId, false);
if (local is not null)
{
DetachAdditionalUser(local, userId);
QueueDirectoryPublish(local);
}
}
private static void AttachAdditionalUser(SessionInfo session, Guid userId, string userName)
{
if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId)))
{
session.AdditionalUsers = [.. session.AdditionalUsers, new SessionUserInfo { UserId = userId, UserName = userName }];
}
}
private static void DetachAdditionalUser(SessionInfo session, Guid userId)
{
var existing = session.AdditionalUsers.FirstOrDefault(i => i.UserId.Equals(userId));
if (existing is not null)
{
var list = session.AdditionalUsers.ToList();
list.Remove(existing);
session.AdditionalUsers = list.ToArray();
}
}
// The owner is the instance whose copy of the session is the one everyone else is shown, so the
// change has to be applied there as well as on whichever instance served the request.
private async Task RouteAdditionalUserChange(string sessionId, Guid userId, bool add)
{
if (!_directoryEnabled)
{
return;
}
var entry = await _sessionDirectory.GetAsync(sessionId).ConfigureAwait(false);
if (entry is null || string.Equals(entry.OwnerPod, _podMessageBus.PodId, StringComparison.Ordinal))
{
return;
}
var payload = new RoutedAdditionalUserChange
{
SessionId = sessionId,
UserId = userId,
Add = add
};
var delivered = await _podMessageBus.RequestAsync(
entry.OwnerPod,
new PodMessage
{
Kind = RoutedAdditionalUserChange.Kind,
Payload = JsonSerializer.Serialize(payload, JsonDefaults.Options)
}).ConfigureAwait(false);
if (!delivered)
{
throw new ResourceNotFoundException(
string.Format(CultureInfo.InvariantCulture, "The instance holding session {0} did not apply the change.", sessionId));
}
}
///
/// Authenticates the new session.
///
/// The authenticationrequest.
/// The authentication result.
public Task AuthenticateNewSession(AuthenticationRequest request)
{
return AuthenticateNewSessionInternal(request, true);
}
///
/// Directly authenticates the session without enforcing password.
///
/// The authentication request.
/// The authentication result.
public Task AuthenticateDirect(AuthenticationRequest request)
{
return AuthenticateNewSessionInternal(request, false);
}
internal async Task AuthenticateNewSessionInternal(AuthenticationRequest request, bool enforcePassword)
{
CheckDisposed();
ArgumentException.ThrowIfNullOrEmpty(request.App);
ArgumentException.ThrowIfNullOrEmpty(request.DeviceId);
ArgumentException.ThrowIfNullOrEmpty(request.DeviceName);
ArgumentException.ThrowIfNullOrEmpty(request.AppVersion);
User user = null;
if (!request.UserId.IsEmpty())
{
user = _userManager.GetUserById(request.UserId);
}
user ??= _userManager.GetUserByName(request.Username);
if (enforcePassword)
{
user = await _userManager.AuthenticateUser(
request.Username,
request.Password,
request.RemoteEndPoint,
true).ConfigureAwait(false);
}
if (user is null)
{
await _eventManager.PublishAsync(new AuthenticationRequestEventArgs(request)).ConfigureAwait(false);
throw new AuthenticationException("Invalid username or password entered.");
}
if (!string.IsNullOrEmpty(request.DeviceId)
&& !_deviceManager.CanAccessDevice(user, request.DeviceId))
{
throw new SecurityException("User is not allowed access from this device.");
}
int sessionsCount = Sessions.Count(i => i.UserId.Equals(user.Id));
int maxActiveSessions = user.MaxActiveSessions;
_logger.LogInformation("Current/Max sessions for user {User}: {Sessions}/{Max}", user.Username, sessionsCount, maxActiveSessions);
if (maxActiveSessions >= 1 && sessionsCount >= maxActiveSessions)
{
throw new SecurityException("User is at their maximum number of sessions.");
}
var token = await GetAuthorizationToken(user, request.DeviceId, request.App, request.AppVersion, request.DeviceName).ConfigureAwait(false);
var session = await LogSessionActivity(
request.App,
request.AppVersion,
request.DeviceId,
request.DeviceName,
request.RemoteEndPoint,
user).ConfigureAwait(false);
var returnResult = new AuthenticationResult
{
User = _userManager.GetUserDto(user, request.RemoteEndPoint),
SessionInfo = ToSessionInfoDto(session),
AccessToken = token,
ServerId = _appHost.SystemId
};
await _eventManager.PublishAsync(new AuthenticationResultEventArgs(returnResult)).ConfigureAwait(false);
return returnResult;
}
internal async Task GetAuthorizationToken(User user, string deviceId, string app, string appVersion, string deviceName)
{
// This should be validated above, but if it isn't don't delete all tokens.
ArgumentException.ThrowIfNullOrEmpty(deviceId);
var existing = (await _deviceManager.GetDevices(
new DeviceQuery
{
DeviceId = deviceId,
UserId = user.Id
}).ConfigureAwait(false)).Items;
foreach (var auth in existing)
{
try
{
// Logout any existing sessions for the user on this device
await Logout(auth).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while logging out existing session.");
}
}
_logger.LogInformation("Creating new access token for user {0}", user.Id);
var device = await _deviceManager.CreateDevice(new Device(user.Id, app, appVersion, deviceName, deviceId)).ConfigureAwait(false);
return device.AccessToken;
}
///
public async Task Logout(string accessToken)
{
CheckDisposed();
ArgumentException.ThrowIfNullOrEmpty(accessToken);
var existing = (await _deviceManager.GetDevices(
new DeviceQuery
{
Limit = 1,
AccessToken = accessToken
}).ConfigureAwait(false)).Items;
if (existing.Count > 0)
{
await Logout(existing[0]).ConfigureAwait(false);
}
}
///
public async Task Logout(Device device)
{
CheckDisposed();
_logger.LogInformation("Logging out access token {0}", device.AccessToken);
await _deviceManager.DeleteDevice(device).ConfigureAwait(false);
var sessions = Sessions
.Where(i => string.Equals(i.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var session in sessions)
{
try
{
await ReportSessionEnded(session.Id).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reporting session ended");
}
}
}
///
public async Task RevokeUserTokens(Guid userId, string currentAccessToken)
{
CheckDisposed();
var existing = await _deviceManager.GetDevices(new DeviceQuery
{
UserId = userId
}).ConfigureAwait(false);
foreach (var info in existing.Items)
{
if (!string.Equals(currentAccessToken, info.AccessToken, StringComparison.OrdinalIgnoreCase))
{
await Logout(info).ConfigureAwait(false);
}
}
}
///
/// Reports the capabilities.
///
/// 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 async Task ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities)
{
CheckDisposed();
var session = await GetSessionForControl(sessionId).ConfigureAwait(false);
if (!string.IsNullOrEmpty(controllingSessionId))
{
AssertCanControl(session, await GetSessionForControl(controllingSessionId).ConfigureAwait(false));
}
// 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(
SessionInfo session,
ClientCapabilities capabilities,
bool saveCapabilities)
{
session.Capabilities = capabilities;
if (saveCapabilities)
{
CapabilitiesChanged?.Invoke(
this,
new SessionEventArgs
{
SessionInfo = 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);
}
}
///
/// Converts a BaseItem to a BaseItemInfo.
///
private BaseItemDto GetItemInfo(BaseItem item, MediaSourceInfo mediaSource)
{
ArgumentNullException.ThrowIfNull(item);
var dtoOptions = _itemInfoDtoOptions;
if (_itemInfoDtoOptions is null)
{
dtoOptions = new DtoOptions
{
AddProgramRecordingInfo = false
};
var fields = dtoOptions.Fields.ToList();
fields.Remove(ItemFields.CanDelete);
fields.Remove(ItemFields.CanDownload);
fields.Remove(ItemFields.ChildCount);
fields.Remove(ItemFields.CustomRating);
fields.Remove(ItemFields.DateLastMediaAdded);
fields.Remove(ItemFields.DateLastRefreshed);
fields.Remove(ItemFields.DateLastSaved);
fields.Remove(ItemFields.DisplayPreferencesId);
fields.Remove(ItemFields.Etag);
fields.Remove(ItemFields.ItemCounts);
fields.Remove(ItemFields.MediaSourceCount);
fields.Remove(ItemFields.MediaStreams);
fields.Remove(ItemFields.MediaSources);
fields.Remove(ItemFields.People);
fields.Remove(ItemFields.PlayAccess);
fields.Remove(ItemFields.People);
fields.Remove(ItemFields.ProductionLocations);
fields.Remove(ItemFields.RecursiveItemCount);
fields.Remove(ItemFields.RemoteTrailers);
fields.Remove(ItemFields.SeasonUserData);
fields.Remove(ItemFields.Settings);
fields.Remove(ItemFields.SortName);
fields.Remove(ItemFields.Tags);
dtoOptions.Fields = fields.ToArray();
_itemInfoDtoOptions = dtoOptions;
}
var info = _dtoService.GetBaseItemDto(item, dtoOptions);
if (mediaSource is not null)
{
info.MediaStreams = mediaSource.MediaStreams.ToArray();
}
return info;
}
private string GetImageCacheTag(User user)
{
try
{
return _imageProcessor.GetImageCacheTag(user);
}
catch (Exception e)
{
_logger.LogError(e, "Error getting image information for profile image");
return null;
}
}
///
public async Task ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId)
{
ArgumentException.ThrowIfNullOrEmpty(itemId);
var session = await GetSessionForControl(sessionId).ConfigureAwait(false);
if (!string.IsNullOrEmpty(controllingSessionId))
{
AssertCanControl(session, await GetSessionForControl(controllingSessionId).ConfigureAwait(false));
}
var payload = new RoutedNowViewingItem { SessionId = sessionId, ItemId = itemId };
if (await TryRouteToOwnerAsync(sessionId, RoutedNowViewingItem.Kind, payload, CancellationToken.None).ConfigureAwait(false))
{
return;
}
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)
{
session.NowViewingItem = GetItemInfo(_libraryManager.GetItemById(new Guid(itemId)), null);
QueueDirectoryPublish(session);
}
///
public void ReportTranscodingInfo(string deviceId, TranscodingInfo info)
{
var session = Sessions.FirstOrDefault(i =>
string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase));
if (session is not null)
{
session.TranscodingInfo = info;
}
}
///
public void ClearTranscodingInfo(string deviceId)
{
ReportTranscodingInfo(deviceId, null);
}
///
public SessionInfo GetSession(string deviceId, string client, string version)
{
return Sessions.FirstOrDefault(i =>
string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)
&& string.Equals(i.Client, client, StringComparison.OrdinalIgnoreCase));
}
///
public Task GetSessionByAuthenticationToken(Device info, string deviceId, string remoteEndpoint, string appVersion)
{
ArgumentNullException.ThrowIfNull(info);
var user = info.UserId.IsEmpty()
? null
: _userManager.GetUserById(info.UserId);
appVersion = string.IsNullOrEmpty(appVersion)
? info.AppVersion
: appVersion;
var deviceName = info.DeviceName;
var appName = info.AppName;
if (string.IsNullOrEmpty(deviceId))
{
deviceId = info.DeviceId;
}
// Prevent argument exception
if (string.IsNullOrEmpty(appVersion))
{
appVersion = "1";
}
return LogSessionActivity(appName, appVersion, deviceId, deviceName, remoteEndpoint, user);
}
///
public async Task GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint)
{
var items = (await _deviceManager.GetDevices(new DeviceQuery
{
AccessToken = token,
Limit = 1
}).ConfigureAwait(false)).Items;
if (items.Count == 0)
{
return null;
}
return await GetSessionByAuthenticationToken(items[0], deviceId, remoteEndpoint, null).ConfigureAwait(false);
}
///
public async Task> GetSessions(
Guid userId,
string deviceId,
int? activeWithinSeconds,
Guid? controllableUserToCheck,
bool isApiKey,
CancellationToken cancellationToken)
{
var remote = await GetRemoteEntriesAsync(cancellationToken).ConfigureAwait(false);
var ownedElsewhere = remote.Select(entry => entry.Session.Id).ToHashSet(StringComparer.Ordinal);
// A session this instance only holds a copy of is reported by its owner, whose controllers are
// the ones that decide whether it is active and controllable.
IEnumerable result = Sessions
.Where(i => !ownedElsewhere.Contains(i.Id))
.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));
}
var userCanControlOthers = false;
var userIsAdmin = false;
User user = null;
if (isApiKey)
{
userCanControlOthers = true;
userIsAdmin = true;
}
else if (!userId.IsEmpty())
{
user = _userManager.GetUserById(userId);
if (user is not null)
{
userCanControlOthers = user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers);
userIsAdmin = user.HasPermission(PermissionKind.IsAdministrator);
}
else
{
return [];
}
}
if (!controllableUserToCheck.IsNullOrEmpty())
{
result = result.Where(i => i.SupportsRemoteControl);
var controlledUser = _userManager.GetUserById(controllableUserToCheck.Value);
if (controlledUser is null)
{
return [];
}
if (!controlledUser.HasPermission(PermissionKind.EnableSharedDeviceControl))
{
// Controlled user has device sharing disabled
result = result.Where(i => !i.UserId.IsEmpty());
}
if (!userCanControlOthers)
{
// User cannot control other user's sessions, validate user id.
result = result.Where(i => i.UserId.IsEmpty() || ContainsUser(i, userId));
}
result = result.Where(i =>
{
if (isApiKey)
{
return true;
}
if (user is null)
{
return false;
}
return string.IsNullOrWhiteSpace(i.DeviceId) || _deviceManager.CanAccessDevice(user, i.DeviceId);
});
}
else if (!userIsAdmin)
{
// Request isn't from administrator, limit to "own" sessions.
result = result.Where(i => i.UserId.IsEmpty() || ContainsUser(i, userId));
}
if (!userIsAdmin)
{
// Don't report acceleration type for non-admin users.
result = result.Select(r =>
{
if (r.TranscodingInfo is not null)
{
r.TranscodingInfo.HardwareAccelerationType = HardwareAccelerationType.none;
}
return r;
});
}
if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0)
{
var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value);
result = result.Where(i => i.LastActivityDate >= minActiveDate);
}
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));
}
///
public Task SendMessageToAdminSessions(SessionMessageType name, T data, CancellationToken cancellationToken)
{
CheckDisposed();
var adminUserIds = _userManager.GetUsers()
.Where(i => i.HasPermission(PermissionKind.IsAdministrator))
.Select(i => i.Id)
.ToList();
return SendMessageToUserSessions(adminUserIds, name, data, cancellationToken);
}
///
public Task SendMessageToUserSessions(List userIds, SessionMessageType name, Func dataFn, CancellationToken cancellationToken)
{
CheckDisposed();
var sessions = Sessions.Where(i => userIds.Any(i.ContainsUser)).ToList();
if (sessions.Count == 0)
{
return Task.CompletedTask;
}
return SendMessageToSessions(sessions, name, dataFn(), cancellationToken);
}
///
public Task SendMessageToUserSessions(List userIds, SessionMessageType name, T data, CancellationToken cancellationToken)
{
CheckDisposed();
var sessions = Sessions.Where(i => userIds.Any(i.ContainsUser));
return SendMessageToSessions(sessions, name, data, cancellationToken);
}
///
public Task SendMessageToUserDeviceSessions(string deviceId, SessionMessageType name, T data, CancellationToken cancellationToken)
{
CheckDisposed();
var sessions = Sessions.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase));
return SendMessageToSessions(sessions, name, data, cancellationToken);
}
///
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
foreach (var session in _activeConnections.Values)
{
await session.DisposeAsync().ConfigureAwait(false);
}
if (_idleTimer is not null)
{
await _idleTimer.DisposeAsync().ConfigureAwait(false);
_idleTimer = null;
}
if (_inactiveTimer is not null)
{
await _inactiveTimer.DisposeAsync().ConfigureAwait(false);
_inactiveTimer = null;
}
if (_directoryTimer is not null)
{
await _directoryTimer.DisposeAsync().ConfigureAwait(false);
_directoryTimer = null;
}
await _shutdownCallback.DisposeAsync().ConfigureAwait(false);
_deviceManager.DeviceOptionsUpdated -= OnDeviceManagerDeviceOptionsUpdated;
_disposed = true;
}
private async void OnApplicationStopping()
{
_logger.LogInformation("Sending shutdown notifications");
try
{
var messageType = _appHost.ShouldRestart ? SessionMessageType.ServerRestarting : SessionMessageType.ServerShuttingDown;
await SendMessageToSessions(Sessions, messageType, string.Empty, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending server shutdown notifications");
}
// 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);
}
_activeConnections.Clear();
_activeLiveStreamSessions.Clear();
}
}
}