aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations')
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs6
-rw-r--r--Emby.Server.Implementations/SyncPlay/GroupController.cs668
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayController.cs514
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs481
4 files changed, 949 insertions, 720 deletions
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index afddfa856b..b3965fccad 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -1181,18 +1181,16 @@ namespace Emby.Server.Implementations.Session
}
/// <inheritdoc />
- public async Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken)
+ public async Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken)
{
CheckDisposed();
- var session = GetSessionToRemoteControl(sessionId);
await SendMessageToSession(session, SessionMessageType.SyncPlayCommand, command, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
- public async Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken)
+ public async Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken)
{
CheckDisposed();
- var session = GetSessionToRemoteControl(sessionId);
await SendMessageToSession(session, SessionMessageType.SyncPlayGroupUpdate, command, cancellationToken).ConfigureAwait(false);
}
diff --git a/Emby.Server.Implementations/SyncPlay/GroupController.cs b/Emby.Server.Implementations/SyncPlay/GroupController.cs
new file mode 100644
index 0000000000..31df8404b4
--- /dev/null
+++ b/Emby.Server.Implementations/SyncPlay/GroupController.cs
@@ -0,0 +1,668 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay;
+using MediaBrowser.Controller.SyncPlay.GroupStates;
+using MediaBrowser.Controller.SyncPlay.Queue;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.SyncPlay
+{
+ /// <summary>
+ /// Class GroupController.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public class GroupController : IGroupController, IGroupStateContext
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<GroupController> _logger;
+
+ /// <summary>
+ /// The logger factory.
+ /// </summary>
+ private readonly ILoggerFactory _loggerFactory;
+
+ /// <summary>
+ /// The user manager.
+ /// </summary>
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// The session manager.
+ /// </summary>
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// The library manager.
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// The participants, or members of the group.
+ /// </summary>
+ private readonly Dictionary<string, GroupMember> _participants =
+ new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase);
+
+ /// <summary>
+ /// The internal group state.
+ /// </summary>
+ private IGroupState _state;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="GroupController" /> class.
+ /// </summary>
+ /// <param name="loggerFactory">The logger factory.</param>
+ /// <param name="userManager">The user manager.</param>
+ /// <param name="sessionManager">The session manager.</param>
+ /// <param name="libraryManager">The library manager.</param>
+ public GroupController(
+ ILoggerFactory loggerFactory,
+ IUserManager userManager,
+ ISessionManager sessionManager,
+ ILibraryManager libraryManager)
+ {
+ _loggerFactory = loggerFactory;
+ _userManager = userManager;
+ _sessionManager = sessionManager;
+ _libraryManager = libraryManager;
+ _logger = loggerFactory.CreateLogger<GroupController>();
+
+ _state = new IdleGroupState(loggerFactory);
+ }
+
+ /// <summary>
+ /// Gets the default ping value used for sessions.
+ /// </summary>
+ /// <value>The default ping.</value>
+ public long DefaultPing { get; } = 500;
+
+ /// <summary>
+ /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
+ /// </summary>
+ /// <value>The maximum time offset error.</value>
+ public long TimeSyncOffset { get; } = 2000;
+
+ /// <summary>
+ /// Gets the maximum offset error accepted for position reported by clients, in milliseconds.
+ /// </summary>
+ /// <value>The maximum offset error.</value>
+ public long MaxPlaybackOffset { get; } = 500;
+
+ /// <summary>
+ /// Gets the group identifier.
+ /// </summary>
+ /// <value>The group identifier.</value>
+ public Guid GroupId { get; } = Guid.NewGuid();
+
+ /// <summary>
+ /// Gets the group name.
+ /// </summary>
+ /// <value>The group name.</value>
+ public string GroupName { get; private set; }
+
+ /// <summary>
+ /// Gets the group identifier.
+ /// </summary>
+ /// <value>The group identifier.</value>
+ public PlayQueueManager PlayQueue { get; } = new PlayQueueManager();
+
+ /// <summary>
+ /// Gets the runtime ticks of current playing item.
+ /// </summary>
+ /// <value>The runtime ticks of current playing item.</value>
+ public long RunTimeTicks { get; private set; }
+
+ /// <summary>
+ /// Gets or sets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; set; }
+
+ /// <summary>
+ /// Gets or sets the last activity.
+ /// </summary>
+ /// <value>The last activity.</value>
+ public DateTime LastActivity { get; set; }
+
+ /// <summary>
+ /// Adds the session to the group.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ private void AddSession(SessionInfo session)
+ {
+ _participants.TryAdd(
+ session.Id,
+ new GroupMember(session)
+ {
+ Ping = DefaultPing,
+ IsBuffering = false
+ });
+ }
+
+ /// <summary>
+ /// Removes the session from the group.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ private void RemoveSession(SessionInfo session)
+ {
+ _participants.Remove(session.Id);
+ }
+
+ /// <summary>
+ /// Filters sessions of this group.
+ /// </summary>
+ /// <param name="from">The current session.</param>
+ /// <param name="type">The filtering type.</param>
+ /// <returns>The list of sessions matching the filter.</returns>
+ private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, SyncPlayBroadcastType type)
+ {
+ return type switch
+ {
+ SyncPlayBroadcastType.CurrentSession => new SessionInfo[] { from },
+ SyncPlayBroadcastType.AllGroup => _participants
+ .Values
+ .Select(session => session.Session),
+ SyncPlayBroadcastType.AllExceptCurrentSession => _participants
+ .Values
+ .Select(session => session.Session)
+ .Where(session => !session.Id.Equals(from.Id, StringComparison.OrdinalIgnoreCase)),
+ SyncPlayBroadcastType.AllReady => _participants
+ .Values
+ .Where(session => !session.IsBuffering)
+ .Select(session => session.Session),
+ _ => Enumerable.Empty<SessionInfo>()
+ };
+ }
+
+ /// <summary>
+ /// Checks if a given user can access a given item, that is, the user has access to a folder where the item is stored.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="item">The item.</param>
+ /// <returns><c>true</c> if the user can access the item, <c>false</c> otherwise.</returns>
+ private bool HasAccessToItem(User user, BaseItem item)
+ {
+ var collections = _libraryManager.GetCollectionFolders(item)
+ .Select(folder => folder.Id.ToString("N", CultureInfo.InvariantCulture));
+ return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any();
+ }
+
+ /// <summary>
+ /// Checks if a given user can access all items of a given queue, that is,
+ /// the user has the required minimum parental access and has access to all required folders.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="queue">The queue.</param>
+ /// <returns><c>true</c> if the user can access all the items in the queue, <c>false</c> otherwise.</returns>
+ private bool HasAccessToQueue(User user, IReadOnlyList<Guid> queue)
+ {
+ // Check if queue is empty.
+ if (queue == null || queue.Count == 0)
+ {
+ return true;
+ }
+
+ foreach (var itemId in queue)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+ if (user.MaxParentalAgeRating.HasValue && item.InheritedParentalRatingValue > user.MaxParentalAgeRating)
+ {
+ return false;
+ }
+
+ if (!user.HasPermission(PermissionKind.EnableAllFolders) && !HasAccessToItem(user, item))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool AllUsersHaveAccessToQueue(IReadOnlyList<Guid> queue)
+ {
+ // Check if queue is empty.
+ if (queue == null || queue.Count == 0)
+ {
+ return true;
+ }
+
+ // Get list of users.
+ var users = _participants
+ .Values
+ .Select(participant => _userManager.GetUserById(participant.Session.UserId));
+
+ // Find problematic users.
+ var usersWithNoAccess = users.Where(user => !HasAccessToQueue(user, queue));
+
+ // All users must be able to access the queue.
+ return !usersWithNoAccess.Any();
+ }
+
+ /// <inheritdoc />
+ public bool IsGroupEmpty() => _participants.Count == 0;
+
+ /// <inheritdoc />
+ public void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken)
+ {
+ GroupName = request.GroupName;
+ AddSession(session);
+
+ var sessionIsPlayingAnItem = session.FullNowPlayingItem != null;
+
+ RestartCurrentItem();
+
+ if (sessionIsPlayingAnItem)
+ {
+ var playlist = session.NowPlayingQueue.Select(item => item.Id).ToList();
+ PlayQueue.Reset();
+ PlayQueue.SetPlaylist(playlist);
+ PlayQueue.SetPlayingItemById(session.FullNowPlayingItem.Id);
+ RunTimeTicks = session.FullNowPlayingItem.RunTimeTicks ?? 0;
+ PositionTicks = session.PlayState.PositionTicks ?? 0;
+
+ // Maintain playstate.
+ var waitingState = new WaitingGroupState(_loggerFactory)
+ {
+ ResumePlaying = !session.PlayState.IsPaused
+ };
+ SetState(waitingState);
+ }
+
+ var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo());
+ SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
+
+ _state.SessionJoined(this, _state.Type, session, cancellationToken);
+
+ _logger.LogInformation("Session {SessionId} created group {GroupId}.", session.Id, GroupId.ToString());
+ }
+
+ /// <inheritdoc />
+ public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
+ {
+ AddSession(session);
+
+ var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo());
+ SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
+
+ var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName);
+ SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
+
+ _state.SessionJoined(this, _state.Type, session, cancellationToken);
+
+ _logger.LogInformation("Session {SessionId} joined group {GroupId}.", session.Id, GroupId.ToString());
+ }
+
+ /// <inheritdoc />
+ public void SessionRestore(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
+ {
+ var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo());
+ SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
+
+ var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName);
+ SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
+
+ _state.SessionJoined(this, _state.Type, session, cancellationToken);
+
+ _logger.LogInformation("Session {SessionId} re-joined group {GroupId}.", session.Id, GroupId.ToString());
+ }
+
+ /// <inheritdoc />
+ public void SessionLeave(SessionInfo session, CancellationToken cancellationToken)
+ {
+ _state.SessionLeaving(this, _state.Type, session, cancellationToken);
+
+ RemoveSession(session);
+
+ var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, GroupId.ToString());
+ SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
+
+ var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName);
+ SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
+
+ _logger.LogInformation("Session {SessionId} left group {GroupId}.", session.Id, GroupId.ToString());
+ }
+
+ /// <inheritdoc />
+ public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken)
+ {
+ // The server's job is to maintain a consistent state for clients to reference
+ // and notify clients of state changes. The actual syncing of media playback
+ // happens client side. Clients are aware of the server's time and use it to sync.
+ _logger.LogInformation("Session {SessionId} requested {RequestType} in group {GroupId} that is {StateType}.", session.Id, request.Type, GroupId.ToString(), _state.Type);
+ request.Apply(this, _state, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public GroupInfoDto GetInfo()
+ {
+ var participants = _participants.Values.Select(session => session.Session.UserName).Distinct().ToList();
+ return new GroupInfoDto(GroupId, GroupName, _state.Type, participants, DateTime.UtcNow);
+ }
+
+ /// <inheritdoc />
+ public bool HasAccessToPlayQueue(User user)
+ {
+ var items = PlayQueue.GetPlaylist().Select(item => item.ItemId).ToList();
+ return HasAccessToQueue(user, items);
+ }
+
+ /// <inheritdoc />
+ public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait)
+ {
+ if (_participants.TryGetValue(session.Id, out GroupMember value))
+ {
+ value.IgnoreGroupWait = ignoreGroupWait;
+ }
+ }
+
+ /// <inheritdoc />
+ public void SetState(IGroupState state)
+ {
+ _logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type);
+ this._state = state;
+ }
+
+ /// <inheritdoc />
+ public Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken)
+ {
+ IEnumerable<Task> GetTasks()
+ {
+ foreach (var session in FilterSessions(from, type))
+ {
+ yield return _sessionManager.SendSyncPlayGroupUpdate(session, message, cancellationToken);
+ }
+ }
+
+ return Task.WhenAll(GetTasks());
+ }
+
+ /// <inheritdoc />
+ public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken)
+ {
+ IEnumerable<Task> GetTasks()
+ {
+ foreach (var session in FilterSessions(from, type))
+ {
+ yield return _sessionManager.SendSyncPlayCommand(session, message, cancellationToken);
+ }
+ }
+
+ return Task.WhenAll(GetTasks());
+ }
+
+ /// <inheritdoc />
+ public SendCommand NewSyncPlayCommand(SendCommandType type)
+ {
+ return new SendCommand(
+ GroupId,
+ PlayQueue.GetPlayingItemPlaylistId(),
+ LastActivity,
+ type,
+ PositionTicks,
+ DateTime.UtcNow);
+ }
+
+ /// <inheritdoc />
+ public GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data)
+ {
+ return new GroupUpdate<T>(GroupId, type, data);
+ }
+
+ /// <inheritdoc />
+ public long SanitizePositionTicks(long? positionTicks)
+ {
+ var ticks = positionTicks ?? 0;
+ return Math.Clamp(ticks, 0, RunTimeTicks);
+ }
+
+ /// <inheritdoc />
+ public void UpdatePing(SessionInfo session, long ping)
+ {
+ if (_participants.TryGetValue(session.Id, out GroupMember value))
+ {
+ value.Ping = ping;
+ }
+ }
+
+ /// <inheritdoc />
+ public long GetHighestPing()
+ {
+ long max = long.MinValue;
+ foreach (var session in _participants.Values)
+ {
+ max = Math.Max(max, session.Ping);
+ }
+
+ return max;
+ }
+
+ /// <inheritdoc />
+ public void SetBuffering(SessionInfo session, bool isBuffering)
+ {
+ if (_participants.TryGetValue(session.Id, out GroupMember value))
+ {
+ value.IsBuffering = isBuffering;
+ }
+ }
+
+ /// <inheritdoc />
+ public void SetAllBuffering(bool isBuffering)
+ {
+ foreach (var session in _participants.Values)
+ {
+ session.IsBuffering = isBuffering;
+ }
+ }
+
+ /// <inheritdoc />
+ public bool IsBuffering()
+ {
+ foreach (var session in _participants.Values)
+ {
+ if (session.IsBuffering && !session.IgnoreGroupWait)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// <inheritdoc />
+ public bool SetPlayQueue(IReadOnlyList<Guid> playQueue, int playingItemPosition, long startPositionTicks)
+ {
+ // Ignore on empty queue or invalid item position.
+ if (playQueue.Count == 0 || playingItemPosition >= playQueue.Count || playingItemPosition < 0)
+ {
+ return false;
+ }
+
+ // Check if participants can access the new playing queue.
+ if (!AllUsersHaveAccessToQueue(playQueue))
+ {
+ return false;
+ }
+
+ PlayQueue.Reset();
+ PlayQueue.SetPlaylist(playQueue);
+ PlayQueue.SetPlayingItemByIndex(playingItemPosition);
+ var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId());
+ RunTimeTicks = item.RunTimeTicks ?? 0;
+ PositionTicks = startPositionTicks;
+ LastActivity = DateTime.UtcNow;
+
+ return true;
+ }
+
+ /// <inheritdoc />
+ public bool SetPlayingItem(string playlistItemId)
+ {
+ var itemFound = PlayQueue.SetPlayingItemByPlaylistId(playlistItemId);
+
+ if (itemFound)
+ {
+ var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId());
+ RunTimeTicks = item.RunTimeTicks ?? 0;
+ }
+ else
+ {
+ RunTimeTicks = 0;
+ }
+
+ RestartCurrentItem();
+
+ return itemFound;
+ }
+
+ /// <inheritdoc />
+ public bool RemoveFromPlayQueue(IReadOnlyList<string> playlistItemIds)
+ {
+ var playingItemRemoved = PlayQueue.RemoveFromPlaylist(playlistItemIds);
+ if (playingItemRemoved)
+ {
+ var itemId = PlayQueue.GetPlayingItemId();
+ if (!itemId.Equals(Guid.Empty))
+ {
+ var item = _libraryManager.GetItemById(itemId);
+ RunTimeTicks = item.RunTimeTicks ?? 0;
+ }
+ else
+ {
+ RunTimeTicks = 0;
+ }
+
+ RestartCurrentItem();
+ }
+
+ return playingItemRemoved;
+ }
+
+ /// <inheritdoc />
+ public bool MoveItemInPlayQueue(string playlistItemId, int newIndex)
+ {
+ return PlayQueue.MovePlaylistItem(playlistItemId, newIndex);
+ }
+
+ /// <inheritdoc />
+ public bool AddToPlayQueue(IReadOnlyList<Guid> newItems, GroupQueueMode mode)
+ {
+ // Ignore on empty list.
+ if (newItems.Count == 0)
+ {
+ return false;
+ }
+
+ // Check if participants can access the new playing queue.
+ if (!AllUsersHaveAccessToQueue(newItems))
+ {
+ return false;
+ }
+
+ if (mode.Equals(GroupQueueMode.QueueNext))
+ {
+ PlayQueue.QueueNext(newItems);
+ }
+ else
+ {
+ PlayQueue.Queue(newItems);
+ }
+
+ return true;
+ }
+
+ /// <inheritdoc />
+ public void RestartCurrentItem()
+ {
+ PositionTicks = 0;
+ LastActivity = DateTime.UtcNow;
+ }
+
+ /// <inheritdoc />
+ public bool NextItemInQueue()
+ {
+ var update = PlayQueue.Next();
+ if (update)
+ {
+ var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId());
+ RunTimeTicks = item.RunTimeTicks ?? 0;
+ RestartCurrentItem();
+ return true;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ /// <inheritdoc />
+ public bool PreviousItemInQueue()
+ {
+ var update = PlayQueue.Previous();
+ if (update)
+ {
+ var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId());
+ RunTimeTicks = item.RunTimeTicks ?? 0;
+ RestartCurrentItem();
+ return true;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ /// <inheritdoc />
+ public void SetRepeatMode(GroupRepeatMode mode)
+ {
+ PlayQueue.SetRepeatMode(mode);
+ }
+
+ /// <inheritdoc />
+ public void SetShuffleMode(GroupShuffleMode mode)
+ {
+ PlayQueue.SetShuffleMode(mode);
+ }
+
+ /// <inheritdoc />
+ public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason)
+ {
+ var startPositionTicks = PositionTicks;
+
+ if (_state.Type.Equals(GroupStateType.Playing))
+ {
+ var currentTime = DateTime.UtcNow;
+ var elapsedTime = currentTime - LastActivity;
+ // Elapsed time is negative if event happens
+ // during the delay added to account for latency.
+ // In this phase clients haven't started the playback yet.
+ // In other words, LastActivity is in the future,
+ // when playback unpause is supposed to happen.
+ // Adjust ticks only if playback actually started.
+ startPositionTicks += Math.Max(elapsedTime.Ticks, 0);
+ }
+
+ return new PlayQueueUpdate(
+ reason,
+ PlayQueue.LastChange,
+ PlayQueue.GetPlaylist(),
+ PlayQueue.PlayingItemIndex,
+ startPositionTicks,
+ PlayQueue.ShuffleMode,
+ PlayQueue.RepeatMode);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
deleted file mode 100644
index 5384795122..0000000000
--- a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
+++ /dev/null
@@ -1,514 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Session;
-using MediaBrowser.Controller.SyncPlay;
-using MediaBrowser.Model.Session;
-using MediaBrowser.Model.SyncPlay;
-
-namespace Emby.Server.Implementations.SyncPlay
-{
- /// <summary>
- /// Class SyncPlayController.
- /// </summary>
- /// <remarks>
- /// Class is not thread-safe, external locking is required when accessing methods.
- /// </remarks>
- public class SyncPlayController : ISyncPlayController
- {
- /// <summary>
- /// Used to filter the sessions of a group.
- /// </summary>
- private enum BroadcastType
- {
- /// <summary>
- /// All sessions will receive the message.
- /// </summary>
- AllGroup = 0,
-
- /// <summary>
- /// Only the specified session will receive the message.
- /// </summary>
- CurrentSession = 1,
-
- /// <summary>
- /// All sessions, except the current one, will receive the message.
- /// </summary>
- AllExceptCurrentSession = 2,
-
- /// <summary>
- /// Only sessions that are not buffering will receive the message.
- /// </summary>
- AllReady = 3
- }
-
- /// <summary>
- /// The session manager.
- /// </summary>
- private readonly ISessionManager _sessionManager;
-
- /// <summary>
- /// The SyncPlay manager.
- /// </summary>
- private readonly ISyncPlayManager _syncPlayManager;
-
- /// <summary>
- /// The group to manage.
- /// </summary>
- private readonly GroupInfo _group = new GroupInfo();
-
- /// <summary>
- /// Initializes a new instance of the <see cref="SyncPlayController" /> class.
- /// </summary>
- /// <param name="sessionManager">The session manager.</param>
- /// <param name="syncPlayManager">The SyncPlay manager.</param>
- public SyncPlayController(
- ISessionManager sessionManager,
- ISyncPlayManager syncPlayManager)
- {
- _sessionManager = sessionManager;
- _syncPlayManager = syncPlayManager;
- }
-
- /// <inheritdoc />
- public Guid GetGroupId() => _group.GroupId;
-
- /// <inheritdoc />
- public Guid GetPlayingItemId() => _group.PlayingItem.Id;
-
- /// <inheritdoc />
- public bool IsGroupEmpty() => _group.IsEmpty();
-
- /// <summary>
- /// Converts DateTime to UTC string.
- /// </summary>
- /// <param name="date">The date to convert.</param>
- /// <value>The UTC string.</value>
- private string DateToUTCString(DateTime date)
- {
- return date.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture);
- }
-
- /// <summary>
- /// Filters sessions of this group.
- /// </summary>
- /// <param name="from">The current session.</param>
- /// <param name="type">The filtering type.</param>
- /// <value>The array of sessions matching the filter.</value>
- private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, BroadcastType type)
- {
- switch (type)
- {
- case BroadcastType.CurrentSession:
- return new SessionInfo[] { from };
- case BroadcastType.AllGroup:
- return _group.Participants.Values
- .Select(session => session.Session);
- case BroadcastType.AllExceptCurrentSession:
- return _group.Participants.Values
- .Select(session => session.Session)
- .Where(session => !session.Id.Equals(from.Id, StringComparison.Ordinal));
- case BroadcastType.AllReady:
- return _group.Participants.Values
- .Where(session => !session.IsBuffering)
- .Select(session => session.Session);
- default:
- return Array.Empty<SessionInfo>();
- }
- }
-
- /// <summary>
- /// Sends a GroupUpdate message to the interested sessions.
- /// </summary>
- /// <param name="from">The current session.</param>
- /// <param name="type">The filtering type.</param>
- /// <param name="message">The message to send.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <value>The task.</value>
- private Task SendGroupUpdate<T>(SessionInfo from, BroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken)
- {
- IEnumerable<Task> GetTasks()
- {
- foreach (var session in FilterSessions(from, type))
- {
- yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken);
- }
- }
-
- return Task.WhenAll(GetTasks());
- }
-
- /// <summary>
- /// Sends a playback command to the interested sessions.
- /// </summary>
- /// <param name="from">The current session.</param>
- /// <param name="type">The filtering type.</param>
- /// <param name="message">The message to send.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <value>The task.</value>
- private Task SendCommand(SessionInfo from, BroadcastType type, SendCommand message, CancellationToken cancellationToken)
- {
- IEnumerable<Task> GetTasks()
- {
- foreach (var session in FilterSessions(from, type))
- {
- yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken);
- }
- }
-
- return Task.WhenAll(GetTasks());
- }
-
- /// <summary>
- /// Builds a new playback command with some default values.
- /// </summary>
- /// <param name="type">The command type.</param>
- /// <value>The SendCommand.</value>
- private SendCommand NewSyncPlayCommand(SendCommandType type)
- {
- return new SendCommand()
- {
- GroupId = _group.GroupId.ToString(),
- Command = type,
- PositionTicks = _group.PositionTicks,
- When = DateToUTCString(_group.LastActivity),
- EmittedAt = DateToUTCString(DateTime.UtcNow)
- };
- }
-
- /// <summary>
- /// Builds a new group update message.
- /// </summary>
- /// <param name="type">The update type.</param>
- /// <param name="data">The data to send.</param>
- /// <value>The GroupUpdate.</value>
- private GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data)
- {
- return new GroupUpdate<T>()
- {
- GroupId = _group.GroupId.ToString(),
- Type = type,
- Data = data
- };
- }
-
- /// <inheritdoc />
- public void CreateGroup(SessionInfo session, CancellationToken cancellationToken)
- {
- _group.AddSession(session);
- _syncPlayManager.AddSessionToGroup(session, this);
-
- _group.PlayingItem = session.FullNowPlayingItem;
- _group.IsPaused = session.PlayState.IsPaused;
- _group.PositionTicks = session.PlayState.PositionTicks ?? 0;
- _group.LastActivity = DateTime.UtcNow;
-
- var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow));
- SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
- }
-
- /// <inheritdoc />
- public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
- {
- if (session.NowPlayingItem?.Id == _group.PlayingItem.Id)
- {
- _group.AddSession(session);
- _syncPlayManager.AddSessionToGroup(session, this);
-
- var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow));
- SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
-
- var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName);
- SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
-
- // Syncing will happen client-side
- if (!_group.IsPaused)
- {
- var playCommand = NewSyncPlayCommand(SendCommandType.Play);
- SendCommand(session, BroadcastType.CurrentSession, playCommand, cancellationToken);
- }
- else
- {
- var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause);
- SendCommand(session, BroadcastType.CurrentSession, pauseCommand, cancellationToken);
- }
- }
- else
- {
- var playRequest = new PlayRequest
- {
- ItemIds = new Guid[] { _group.PlayingItem.Id },
- StartPositionTicks = _group.PositionTicks
- };
- var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest);
- SendGroupUpdate(session, BroadcastType.CurrentSession, update, cancellationToken);
- }
- }
-
- /// <inheritdoc />
- public void SessionLeave(SessionInfo session, CancellationToken cancellationToken)
- {
- _group.RemoveSession(session);
- _syncPlayManager.RemoveSessionFromGroup(session, this);
-
- var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, _group.PositionTicks);
- SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
-
- var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName);
- SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
- }
-
- /// <inheritdoc />
- public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
- {
- // The server's job is to maintain a consistent state for clients to reference
- // and notify clients of state changes. The actual syncing of media playback
- // happens client side. Clients are aware of the server's time and use it to sync.
- switch (request.Type)
- {
- case PlaybackRequestType.Play:
- HandlePlayRequest(session, request, cancellationToken);
- break;
- case PlaybackRequestType.Pause:
- HandlePauseRequest(session, request, cancellationToken);
- break;
- case PlaybackRequestType.Seek:
- HandleSeekRequest(session, request, cancellationToken);
- break;
- case PlaybackRequestType.Buffer:
- HandleBufferingRequest(session, request, cancellationToken);
- break;
- case PlaybackRequestType.Ready:
- HandleBufferingDoneRequest(session, request, cancellationToken);
- break;
- case PlaybackRequestType.Ping:
- HandlePingUpdateRequest(session, request);
- break;
- }
- }
-
- /// <summary>
- /// Handles a play action requested by a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The play action.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- private void HandlePlayRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
- {
- if (_group.IsPaused)
- {
- // Pick a suitable time that accounts for latency
- var delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing);
-
- // Unpause group and set starting point in future
- // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position)
- // The added delay does not guarantee, of course, that the command will be received in time
- // Playback synchronization will mainly happen client side
- _group.IsPaused = false;
- _group.LastActivity = DateTime.UtcNow.AddMilliseconds(
- delay);
-
- var command = NewSyncPlayCommand(SendCommandType.Play);
- SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
- }
- else
- {
- // Client got lost, sending current state
- var command = NewSyncPlayCommand(SendCommandType.Play);
- SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
- }
- }
-
- /// <summary>
- /// Handles a pause action requested by a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The pause action.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- private void HandlePauseRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
- {
- if (!_group.IsPaused)
- {
- // Pause group and compute the media playback position
- _group.IsPaused = true;
- var currentTime = DateTime.UtcNow;
- var elapsedTime = currentTime - _group.LastActivity;
- _group.LastActivity = currentTime;
-
- // Seek only if playback actually started
- // Pause request may be issued during the delay added to account for latency
- _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0;
-
- var command = NewSyncPlayCommand(SendCommandType.Pause);
- SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
- }
- else
- {
- // Client got lost, sending current state
- var command = NewSyncPlayCommand(SendCommandType.Pause);
- SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
- }
- }
-
- /// <summary>
- /// Handles a seek action requested by a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The seek action.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- private void HandleSeekRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
- {
- // Sanitize PositionTicks
- var ticks = SanitizePositionTicks(request.PositionTicks);
-
- // Pause and seek
- _group.IsPaused = true;
- _group.PositionTicks = ticks;
- _group.LastActivity = DateTime.UtcNow;
-
- var command = NewSyncPlayCommand(SendCommandType.Seek);
- SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
- }
-
- /// <summary>
- /// Handles a buffering action requested by a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The buffering action.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- private void HandleBufferingRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
- {
- if (!_group.IsPaused)
- {
- // Pause group and compute the media playback position
- _group.IsPaused = true;
- var currentTime = DateTime.UtcNow;
- var elapsedTime = currentTime - _group.LastActivity;
- _group.LastActivity = currentTime;
- _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0;
-
- _group.SetBuffering(session, true);
-
- // Send pause command to all non-buffering sessions
- var command = NewSyncPlayCommand(SendCommandType.Pause);
- SendCommand(session, BroadcastType.AllReady, command, cancellationToken);
-
- var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.GroupWait, session.UserName);
- SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
- }
- else
- {
- // Client got lost, sending current state
- var command = NewSyncPlayCommand(SendCommandType.Pause);
- SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
- }
- }
-
- /// <summary>
- /// Handles a buffering-done action requested by a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The buffering-done action.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- private void HandleBufferingDoneRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
- {
- if (_group.IsPaused)
- {
- _group.SetBuffering(session, false);
-
- var requestTicks = SanitizePositionTicks(request.PositionTicks);
-
- var when = request.When ?? DateTime.UtcNow;
- var currentTime = DateTime.UtcNow;
- var elapsedTime = currentTime - when;
- var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime;
- var delay = _group.PositionTicks - clientPosition.Ticks;
-
- if (_group.IsBuffering())
- {
- // Others are still buffering, tell this client to pause when ready
- var command = NewSyncPlayCommand(SendCommandType.Pause);
- var pauseAtTime = currentTime.AddMilliseconds(delay);
- command.When = DateToUTCString(pauseAtTime);
- SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
- }
- else
- {
- // Let other clients resume as soon as the buffering client catches up
- _group.IsPaused = false;
-
- if (delay > _group.GetHighestPing() * 2)
- {
- // Client that was buffering is recovering, notifying others to resume
- _group.LastActivity = currentTime.AddMilliseconds(
- delay);
- var command = NewSyncPlayCommand(SendCommandType.Play);
- SendCommand(session, BroadcastType.AllExceptCurrentSession, command, cancellationToken);
- }
- else
- {
- // Client, that was buffering, resumed playback but did not update others in time
- delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing);
-
- _group.LastActivity = currentTime.AddMilliseconds(
- delay);
-
- var command = NewSyncPlayCommand(SendCommandType.Play);
- SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
- }
- }
- }
- else
- {
- // Group was not waiting, make sure client has latest state
- var command = NewSyncPlayCommand(SendCommandType.Play);
- SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
- }
- }
-
- /// <summary>
- /// Sanitizes the PositionTicks, considers the current playing item when available.
- /// </summary>
- /// <param name="positionTicks">The PositionTicks.</param>
- /// <value>The sanitized PositionTicks.</value>
- private long SanitizePositionTicks(long? positionTicks)
- {
- var ticks = positionTicks ?? 0;
- ticks = ticks >= 0 ? ticks : 0;
- if (_group.PlayingItem != null)
- {
- var runTimeTicks = _group.PlayingItem.RunTimeTicks ?? 0;
- ticks = ticks > runTimeTicks ? runTimeTicks : ticks;
- }
-
- return ticks;
- }
-
- /// <summary>
- /// Updates ping of a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The update.</param>
- private void HandlePingUpdateRequest(SessionInfo session, PlaybackRequest request)
- {
- // Collected pings are used to account for network latency when unpausing playback
- _group.UpdatePing(session, request.Ping ?? GroupInfo.DefaultPing);
- }
-
- /// <inheritdoc />
- public GroupInfoView GetInfo()
- {
- return new GroupInfoView()
- {
- GroupId = GetGroupId().ToString(),
- PlayingItemName = _group.PlayingItem.Name,
- PlayingItemId = _group.PlayingItem.Id.ToString(),
- PositionTicks = _group.PositionTicks,
- Participants = _group.Participants.Values.Select(session => session.Session.UserName).Distinct().ToList()
- };
- }
- }
-}
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
index 7c4e003112..be94c39825 100644
--- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
+++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
@@ -1,9 +1,7 @@
using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Threading;
-using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
@@ -24,6 +22,11 @@ namespace Emby.Server.Implementations.SyncPlay
private readonly ILogger<SyncPlayManager> _logger;
/// <summary>
+ /// The logger factory.
+ /// </summary>
+ private readonly ILoggerFactory _loggerFactory;
+
+ /// <summary>
/// The user manager.
/// </summary>
private readonly IUserManager _userManager;
@@ -41,50 +44,48 @@ namespace Emby.Server.Implementations.SyncPlay
/// <summary>
/// The map between sessions and groups.
/// </summary>
- private readonly Dictionary<string, ISyncPlayController> _sessionToGroupMap =
- new Dictionary<string, ISyncPlayController>(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary<string, IGroupController> _sessionToGroupMap =
+ new Dictionary<string, IGroupController>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The groups.
/// </summary>
- private readonly Dictionary<Guid, ISyncPlayController> _groups =
- new Dictionary<Guid, ISyncPlayController>();
+ private readonly Dictionary<Guid, IGroupController> _groups =
+ new Dictionary<Guid, IGroupController>();
/// <summary>
/// Lock used for accessing any group.
/// </summary>
private readonly object _groupsLock = new object();
+ /// <summary>
+ /// Lock used for accessing the session-to-group map.
+ /// </summary>
+ private readonly object _mapsLock = new object();
+
private bool _disposed = false;
/// <summary>
/// Initializes a new instance of the <see cref="SyncPlayManager" /> class.
/// </summary>
- /// <param name="logger">The logger.</param>
+ /// <param name="loggerFactory">The logger factory.</param>
/// <param name="userManager">The user manager.</param>
/// <param name="sessionManager">The session manager.</param>
/// <param name="libraryManager">The library manager.</param>
public SyncPlayManager(
- ILogger<SyncPlayManager> logger,
+ ILoggerFactory loggerFactory,
IUserManager userManager,
ISessionManager sessionManager,
ILibraryManager libraryManager)
{
- _logger = logger;
+ _loggerFactory = loggerFactory;
_userManager = userManager;
_sessionManager = sessionManager;
_libraryManager = libraryManager;
-
- _sessionManager.SessionEnded += OnSessionManagerSessionEnded;
- _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped;
+ _logger = loggerFactory.CreateLogger<SyncPlayManager>();
+ _sessionManager.SessionStarted += OnSessionManagerSessionStarted;
}
- /// <summary>
- /// Gets all groups.
- /// </summary>
- /// <value>All groups.</value>
- public IEnumerable<ISyncPlayController> Groups => _groups.Values;
-
/// <inheritdoc />
public void Dispose()
{
@@ -92,287 +93,363 @@ namespace Emby.Server.Implementations.SyncPlay
GC.SuppressFinalize(this);
}
- /// <summary>
- /// Releases unmanaged and optionally managed resources.
- /// </summary>
- /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
- protected virtual void Dispose(bool disposing)
+ /// <inheritdoc />
+ public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken)
{
- if (_disposed)
+ // TODO: create abstract class for GroupRequests to avoid explicit request type here.
+ if (!IsRequestValid(session, GroupRequestType.NewGroup, request))
{
return;
}
- _sessionManager.SessionEnded -= OnSessionManagerSessionEnded;
- _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped;
+ // Locking required to access list of groups.
+ lock (_groupsLock)
+ {
+ // Locking required as session-to-group map will be edited.
+ // Locking the group is not required as it is not visible yet.
+ lock (_mapsLock)
+ {
+ if (IsSessionInGroup(session))
+ {
+ LeaveGroup(session, cancellationToken);
+ }
- _disposed = true;
- }
+ var group = new GroupController(_loggerFactory, _userManager, _sessionManager, _libraryManager);
+ _groups[group.GroupId] = group;
- private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e)
- {
- var session = e.SessionInfo;
- if (!IsSessionInGroup(session))
- {
- return;
+ AddSessionToGroup(session, group);
+ group.CreateGroup(session, request, cancellationToken);
+ }
}
-
- LeaveGroup(session, CancellationToken.None);
}
- private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e)
+ /// <inheritdoc />
+ public void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken)
{
- var session = e.Session;
- if (!IsSessionInGroup(session))
+ // TODO: create abstract class for GroupRequests to avoid explicit request type here.
+ if (!IsRequestValid(session, GroupRequestType.JoinGroup, request))
{
return;
}
- LeaveGroup(session, CancellationToken.None);
- }
-
- private bool IsSessionInGroup(SessionInfo session)
- {
- return _sessionToGroupMap.ContainsKey(session.Id);
- }
-
- private bool HasAccessToItem(User user, Guid itemId)
- {
- var item = _libraryManager.GetItemById(itemId);
-
- // Check ParentalRating access
- var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue
- || item.InheritedParentalRatingValue <= user.MaxParentalAgeRating;
+ var user = _userManager.GetUserById(session.UserId);
- if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess)
+ // Locking required to access list of groups.
+ lock (_groupsLock)
{
- var collections = _libraryManager.GetCollectionFolders(item).Select(
- folder => folder.Id.ToString("N", CultureInfo.InvariantCulture));
+ _groups.TryGetValue(groupId, out IGroupController group);
- return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any();
- }
+ if (group == null)
+ {
+ _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, groupId);
- return hasParentalRatingAccess;
- }
+ var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.GroupDoesNotExist, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
+ return;
+ }
- private Guid? GetSessionGroup(SessionInfo session)
- {
- _sessionToGroupMap.TryGetValue(session.Id, out var group);
- return group?.GetGroupId();
+ // Locking required as session-to-group map will be edited.
+ lock (_mapsLock)
+ {
+ // Group lock required to let other requests end first.
+ lock (group)
+ {
+ if (!group.HasAccessToPlayQueue(user))
+ {
+ _logger.LogWarning("Session {SessionId} tried to join group {GroupId} but does not have access to some content of the playing queue.", session.Id, group.GroupId.ToString());
+
+ var error = new GroupUpdate<string>(group.GroupId, GroupUpdateType.LibraryAccessDenied, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
+ return;
+ }
+
+ if (IsSessionInGroup(session))
+ {
+ if (FindJoinedGroupId(session).Equals(groupId))
+ {
+ group.SessionRestore(session, request, cancellationToken);
+ return;
+ }
+
+ LeaveGroup(session, cancellationToken);
+ }
+
+ AddSessionToGroup(session, group);
+ group.SessionJoin(session, request, cancellationToken);
+ }
+ }
+ }
}
/// <inheritdoc />
- public void NewGroup(SessionInfo session, CancellationToken cancellationToken)
+ public void LeaveGroup(SessionInfo session, CancellationToken cancellationToken)
{
- var user = _userManager.GetUserById(session.UserId);
-
- if (user.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups)
+ // TODO: create abstract class for GroupRequests to avoid explicit request type here.
+ if (!IsRequestValid(session, GroupRequestType.LeaveGroup))
{
- _logger.LogWarning("NewGroup: {0} does not have permission to create groups.", session.Id);
-
- var error = new GroupUpdate<string>
- {
- Type = GroupUpdateType.CreateGroupDenied
- };
-
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
return;
}
+ // Locking required to access list of groups.
lock (_groupsLock)
{
- if (IsSessionInGroup(session))
+ // Locking required as session-to-group map will be edited.
+ lock (_mapsLock)
{
- LeaveGroup(session, cancellationToken);
- }
+ var group = FindJoinedGroup(session);
+ if (group == null)
+ {
+ _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id);
- var group = new SyncPlayController(_sessionManager, this);
- _groups[group.GetGroupId()] = group;
+ var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
+ return;
+ }
- group.CreateGroup(session, cancellationToken);
+ // Group lock required to let other requests end first.
+ lock (group)
+ {
+ RemoveSessionFromGroup(session, group);
+ group.SessionLeave(session, cancellationToken);
+
+ if (group.IsGroupEmpty())
+ {
+ _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId);
+ _groups.Remove(group.GroupId, out _);
+ }
+ }
+ }
}
}
/// <inheritdoc />
- public void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken)
+ public List<GroupInfoDto> ListGroups(SessionInfo session)
{
- var user = _userManager.GetUserById(session.UserId);
-
- if (user.SyncPlayAccess == SyncPlayAccess.None)
+ // TODO: create abstract class for GroupRequests to avoid explicit request type here.
+ if (!IsRequestValid(session, GroupRequestType.ListGroups))
{
- _logger.LogWarning("JoinGroup: {0} does not have access to SyncPlay.", session.Id);
-
- var error = new GroupUpdate<string>()
- {
- Type = GroupUpdateType.JoinGroupDenied
- };
-
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
+ return new List<GroupInfoDto>();
}
+ var user = _userManager.GetUserById(session.UserId);
+ List<GroupInfoDto> list = new List<GroupInfoDto>();
+
+ // Locking required to access list of groups.
lock (_groupsLock)
{
- ISyncPlayController group;
- _groups.TryGetValue(groupId, out group);
-
- if (group == null)
- {
- _logger.LogWarning("JoinGroup: {0} tried to join group {0} that does not exist.", session.Id, groupId);
-
- var error = new GroupUpdate<string>()
- {
- Type = GroupUpdateType.GroupDoesNotExist
- };
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
- }
-
- if (!HasAccessToItem(user, group.GetPlayingItemId()))
- {
- _logger.LogWarning("JoinGroup: {0} does not have access to {1}.", session.Id, group.GetPlayingItemId());
-
- var error = new GroupUpdate<string>()
- {
- GroupId = group.GetGroupId().ToString(),
- Type = GroupUpdateType.LibraryAccessDenied
- };
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
- }
-
- if (IsSessionInGroup(session))
+ foreach (var group in _groups.Values)
{
- if (GetSessionGroup(session).Equals(groupId))
+ // Locking required as group is not thread-safe.
+ lock (group)
{
- return;
+ if (group.HasAccessToPlayQueue(user))
+ {
+ list.Add(group.GetInfo());
+ }
}
-
- LeaveGroup(session, cancellationToken);
}
-
- group.SessionJoin(session, request, cancellationToken);
}
+
+ return list;
}
/// <inheritdoc />
- public void LeaveGroup(SessionInfo session, CancellationToken cancellationToken)
+ public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken)
{
- // TODO: determine what happens to users that are in a group and get their permissions revoked
- lock (_groupsLock)
+ // TODO: create abstract class for GroupRequests to avoid explicit request type here.
+ if (!IsRequestValid(session, GroupRequestType.Playback, request))
{
- _sessionToGroupMap.TryGetValue(session.Id, out var group);
+ return;
+ }
- if (group == null)
- {
- _logger.LogWarning("LeaveGroup: {0} does not belong to any group.", session.Id);
+ var group = FindJoinedGroup(session);
+ if (group == null)
+ {
+ _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id);
- var error = new GroupUpdate<string>()
- {
- Type = GroupUpdateType.NotInGroup
- };
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
- }
+ var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
+ return;
+ }
- group.SessionLeave(session, cancellationToken);
+ // Group lock required as GroupController is not thread-safe.
+ lock (group)
+ {
+ group.HandleRequest(session, request, cancellationToken);
+ }
+ }
- if (group.IsGroupEmpty())
- {
- _logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GetGroupId());
- _groups.Remove(group.GetGroupId(), out _);
- }
+ /// <summary>
+ /// Releases unmanaged and optionally managed resources.
+ /// </summary>
+ /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool disposing)
+ {
+ if (_disposed)
+ {
+ return;
}
+
+ _sessionManager.SessionStarted -= OnSessionManagerSessionStarted;
+ _disposed = true;
}
- /// <inheritdoc />
- public List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId)
+ private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e)
{
- var user = _userManager.GetUserById(session.UserId);
+ var session = e.SessionInfo;
- if (user.SyncPlayAccess == SyncPlayAccess.None)
+ Guid groupId = FindJoinedGroupId(session);
+ if (groupId.Equals(Guid.Empty))
{
- return new List<GroupInfoView>();
+ return;
}
- // Filter by item if requested
- if (!filterItemId.Equals(Guid.Empty))
+ var request = new JoinGroupRequest(groupId);
+ JoinGroup(session, groupId, request, CancellationToken.None);
+ }
+
+ /// <summary>
+ /// Checks if a given session has joined a group.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <returns><c>true</c> if the session has joined a group, <c>false</c> otherwise.</returns>
+ private bool IsSessionInGroup(SessionInfo session)
+ {
+ lock (_mapsLock)
{
- return _groups.Values.Where(
- group => group.GetPlayingItemId().Equals(filterItemId) && HasAccessToItem(user, group.GetPlayingItemId())).Select(
- group => group.GetInfo()).ToList();
+ return _sessionToGroupMap.ContainsKey(session.Id);
}
- else
+ }
+
+ /// <summary>
+ /// Gets the group joined by the given session, if any.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <returns>The group.</returns>
+ private IGroupController FindJoinedGroup(SessionInfo session)
+ {
+ lock (_mapsLock)
{
- // Otherwise show all available groups
- return _groups.Values.Where(
- group => HasAccessToItem(user, group.GetPlayingItemId())).Select(
- group => group.GetInfo()).ToList();
+ _sessionToGroupMap.TryGetValue(session.Id, out var group);
+ return group;
}
}
- /// <inheritdoc />
- public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
+ /// <summary>
+ /// Gets the group identifier joined by the given session, if any.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <returns>The group identifier if the session has joined a group, an empty identifier otherwise.</returns>
+ private Guid FindJoinedGroupId(SessionInfo session)
{
- var user = _userManager.GetUserById(session.UserId);
+ return FindJoinedGroup(session)?.GroupId ?? Guid.Empty;
+ }
- if (user.SyncPlayAccess == SyncPlayAccess.None)
+ /// <summary>
+ /// Maps a session to a group.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="group">The group.</param>
+ /// <exception cref="InvalidOperationException">Thrown when the user is in another group already.</exception>
+ private void AddSessionToGroup(SessionInfo session, IGroupController group)
+ {
+ if (session == null)
{
- _logger.LogWarning("HandleRequest: {0} does not have access to SyncPlay.", session.Id);
+ throw new InvalidOperationException("Session is null!");
+ }
- var error = new GroupUpdate<string>()
+ lock (_mapsLock)
+ {
+ if (IsSessionInGroup(session))
{
- Type = GroupUpdateType.JoinGroupDenied
- };
+ throw new InvalidOperationException("Session in other group already!");
+ }
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
+ _sessionToGroupMap[session.Id] = group ?? throw new InvalidOperationException("Group is null!");
}
+ }
- lock (_groupsLock)
+ /// <summary>
+ /// Unmaps a session from a group.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="group">The group.</param>
+ /// <exception cref="InvalidOperationException">Thrown when the user is not found in the specified group.</exception>
+ private void RemoveSessionFromGroup(SessionInfo session, IGroupController group)
+ {
+ if (session == null)
{
- _sessionToGroupMap.TryGetValue(session.Id, out var group);
+ throw new InvalidOperationException("Session is null!");
+ }
- if (group == null)
- {
- _logger.LogWarning("HandleRequest: {0} does not belong to any group.", session.Id);
+ if (group == null)
+ {
+ throw new InvalidOperationException("Group is null!");
+ }
- var error = new GroupUpdate<string>()
- {
- Type = GroupUpdateType.NotInGroup
- };
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
+ lock (_mapsLock)
+ {
+ if (!IsSessionInGroup(session))
+ {
+ throw new InvalidOperationException("Session not in any group!");
}
- group.HandleRequest(session, request, cancellationToken);
+ _sessionToGroupMap.Remove(session.Id, out var tempGroup);
+ if (!tempGroup.GroupId.Equals(group.GroupId))
+ {
+ throw new InvalidOperationException("Session was in wrong group!");
+ }
}
}
- /// <inheritdoc />
- public void AddSessionToGroup(SessionInfo session, ISyncPlayController group)
+ /// <summary>
+ /// Checks if a given session is allowed to make a given request.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="requestType">The request type.</param>
+ /// <param name="request">The request.</param>
+ /// <param name="checkRequest">Whether to check if request is null.</param>
+ /// <returns><c>true</c> if the request is valid, <c>false</c> otherwise. Will return <c>false</c> also when session is null.</returns>
+ private bool IsRequestValid<T>(SessionInfo session, GroupRequestType requestType, T request, bool checkRequest = true)
{
- if (IsSessionInGroup(session))
+ if (session == null || (request == null && checkRequest))
{
- throw new InvalidOperationException("Session in other group already!");
+ return false;
}
- _sessionToGroupMap[session.Id] = group;
- }
+ var user = _userManager.GetUserById(session.UserId);
- /// <inheritdoc />
- public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group)
- {
- if (!IsSessionInGroup(session))
+ if (user.SyncPlayAccess == SyncPlayAccess.None)
{
- throw new InvalidOperationException("Session not in any group!");
+ _logger.LogWarning("Session {SessionId} requested {RequestType} but does not have access to SyncPlay.", session.Id, requestType);
+
+ // TODO: rename to a more generic error. Next PR will fix this.
+ var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.JoinGroupDenied, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
+ return false;
}
- _sessionToGroupMap.Remove(session.Id, out var tempGroup);
- if (!tempGroup.GetGroupId().Equals(group.GetGroupId()))
+ if (requestType.Equals(GroupRequestType.NewGroup) && user.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups)
{
- throw new InvalidOperationException("Session was in wrong group!");
+ _logger.LogWarning("Session {SessionId} does not have permission to create groups.", session.Id);
+
+ var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.CreateGroupDenied, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
+ return false;
}
+
+ return true;
+ }
+
+ /// <summary>
+ /// Checks if a given session is allowed to make a given type of request.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="requestType">The request type.</param>
+ /// <returns><c>true</c> if the request is valid, <c>false</c> otherwise. Will return <c>false</c> also when session is null.</returns>
+ private bool IsRequestValid(SessionInfo session, GroupRequestType requestType)
+ {
+ return IsRequestValid(session, requestType, session, false);
}
}
}