aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations/Session
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations/Session')
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs162
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs17
2 files changed, 158 insertions, 21 deletions
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 18811ef3a9..94215bed79 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -309,7 +309,7 @@ namespace Emby.Server.Implementations.Session
{
if (!session.SessionControllers.Any(i => i.IsSessionActive))
{
- var key = GetSessionKey(session.Client, session.DeviceId);
+ var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
_activeConnections.TryRemove(key, out _);
if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId))
@@ -343,6 +343,10 @@ namespace Emby.Server.Implementations.Session
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
}
}
+ else
+ {
+ liveStreamNeedsToBeClosed = true;
+ }
if (liveStreamNeedsToBeClosed)
{
@@ -365,7 +369,7 @@ namespace Emby.Server.Implementations.Session
if (session is not null)
{
- var key = GetSessionKey(session.Client, session.DeviceId);
+ var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
_activeConnections.TryRemove(key, out _);
@@ -471,8 +475,11 @@ namespace Emby.Server.Implementations.Session
}
}
- private static string GetSessionKey(string appName, string deviceId)
- => appName + 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);
/// <summary>
/// Gets the connection.
@@ -496,7 +503,7 @@ namespace Emby.Server.Implementations.Session
ArgumentException.ThrowIfNullOrEmpty(deviceId);
- var key = GetSessionKey(appName, deviceId);
+ var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty);
SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
if (ReferenceEquals(newSession, sessionInfo))
@@ -637,8 +644,7 @@ namespace Emby.Server.Implementations.Session
if (playingSessions.Count > 0)
{
var idle = playingSessions
- .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5)
- .ToList();
+ .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5);
foreach (var session in idle)
{
@@ -652,7 +658,7 @@ namespace Emby.Server.Implementations.Session
ItemId = session.NowPlayingItem is null ? Guid.Empty : session.NowPlayingItem.Id,
SessionId = session.Id,
MediaSourceId = session.PlayState?.MediaSourceId,
- PositionTicks = session.PlayState?.PositionTicks
+ PositionTicks = session.LastPlaybackCheckInPositionTicks
}).ConfigureAwait(false);
}
catch (Exception ex)
@@ -726,6 +732,31 @@ namespace Emby.Server.Implementations.Session
}
/// <summary>
+ /// 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 <c>ItemId</c> and the played version as <c>MediaSourceId</c>.
+ /// </summary>
+ /// <param name="libraryItem">The now playing (displayed) item.</param>
+ /// <param name="mediaSourceId">The reported media source id.</param>
+ /// <returns>The item to track progress against.</returns>
+ 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;
+ }
+
+ /// <summary>
/// Used to report that playback has started for an item.
/// </summary>
/// <param name="info">The info.</param>
@@ -756,9 +787,10 @@ namespace Emby.Server.Implementations.Session
if (libraryItem is not null)
{
+ var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
- OnPlaybackStart(user, libraryItem);
+ OnPlaybackStart(user, progressItem);
}
}
@@ -890,9 +922,10 @@ namespace Emby.Server.Implementations.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, libraryItem, info);
+ OnPlaybackProgress(user, progressItem, info);
}
}
@@ -952,6 +985,20 @@ namespace Emby.Server.Implementations.Session
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);
}
}
@@ -1083,9 +1130,10 @@ namespace Emby.Server.Implementations.Session
if (libraryItem is not null)
{
+ var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
- playedToCompletion = OnPlaybackStopped(user, libraryItem, info.PositionTicks, info.Failed);
+ playedToCompletion = OnPlaybackStopped(user, progressItem, info.PositionTicks, info.Failed);
}
}
@@ -1138,6 +1186,14 @@ namespace Emby.Server.Implementations.Session
_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;
}
@@ -1484,11 +1540,52 @@ namespace Emby.Server.Implementations.Session
return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken);
}
- private static void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
+ 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.");
+ }
}
/// <summary>
@@ -1506,16 +1603,24 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// Adds the additional user.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="userId">The user identifier.</param>
- /// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
+ /// <exception cref="SecurityException">The controlling user is not allowed to attach the user to the session.</exception>
/// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception>
- public void AddAdditionalUser(string sessionId, Guid userId)
+ public void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
{
CheckDisposed();
var session = GetSession(sessionId);
+ if (!string.IsNullOrEmpty(controllingSessionId))
+ {
+ var controllingSession = GetSession(controllingSessionId);
+ 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.");
@@ -1523,7 +1628,8 @@ namespace Emby.Server.Implementations.Session
if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId)))
{
- var user = _userManager.GetUserById(userId);
+ var user = _userManager.GetUserById(userId)
+ ?? throw new ArgumentException("The requested user does not exist.");
var newUser = new SessionUserInfo
{
UserId = userId,
@@ -1537,16 +1643,22 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// Removes the additional user.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="userId">The user identifier.</param>
- /// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
+ /// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
/// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception>
- public void RemoveAdditionalUser(string sessionId, Guid userId)
+ public void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
{
CheckDisposed();
var session = GetSession(sessionId);
+ if (!string.IsNullOrEmpty(controllingSessionId))
+ {
+ AssertCanControl(session, GetSession(controllingSessionId));
+ }
+
if (session.UserId.Equals(userId))
{
throw new ArgumentException("The requested user is already the primary user of the session.");
@@ -1750,14 +1862,21 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// Reports the capabilities.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="capabilities">The capabilities.</param>
- public void ReportCapabilities(string sessionId, ClientCapabilities capabilities)
+ /// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
+ public void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities)
{
CheckDisposed();
var session = GetSession(sessionId);
+ if (!string.IsNullOrEmpty(controllingSessionId))
+ {
+ AssertCanControl(session, GetSession(controllingSessionId));
+ }
+
ReportCapabilities(session, capabilities, true);
}
@@ -1852,13 +1971,18 @@ namespace Emby.Server.Implementations.Session
}
/// <inheritdoc />
- public void ReportNowViewingItem(string sessionId, string itemId)
+ public void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId)
{
ArgumentException.ThrowIfNullOrEmpty(itemId);
var item = _libraryManager.GetItemById(new Guid(itemId));
var session = GetSession(sessionId);
+ if (!string.IsNullOrEmpty(controllingSessionId))
+ {
+ AssertCanControl(session, GetSession(controllingSessionId));
+ }
+
session.NowViewingItem = GetItemInfo(item, null);
}
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index 6a26e92e14..e81edc82c6 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
@@ -223,7 +223,7 @@ namespace Emby.Server.Implementations.Session
if (inactive.Count > 0)
{
- _logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
+ _logger.LogDebug("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
}
foreach (var webSocket in inactive)
@@ -246,8 +246,21 @@ namespace Emby.Server.Implementations.Session
_logger.LogInformation("Lost {0} WebSockets.", lost.Count);
foreach (var webSocket in lost)
{
- // TODO: handle session relative to the lost webSocket
RemoveWebSocket(webSocket);
+
+ // The connection stopped answering keep-alives, so a close frame will
+ // never arrive and the pending receive loop would hang forever, keeping
+ // the session (and e.g. its SyncPlay group membership) alive. Disposing
+ // the connection aborts the receive loop, which raises Closed and lets
+ // the session end normally.
+ try
+ {
+ webSocket.Dispose();
+ }
+ catch (Exception exception)
+ {
+ _logger.LogWarning(exception, "Error disposing lost WebSocket from {RemoteEndPoint}.", webSocket.RemoteEndPoint);
+ }
}
}
}