aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CONTRIBUTORS.md1
-rw-r--r--Emby.Server.Implementations/Data/SqliteItemRepository.cs23
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketManager.cs41
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs6
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs80
-rw-r--r--Emby.Server.Implementations/SyncPlay/Group.cs674
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayController.cs514
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs389
-rw-r--r--Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs58
-rw-r--r--Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs33
-rw-r--r--Jellyfin.Api/Constants/Policies.cs10
-rw-r--r--Jellyfin.Api/Controllers/DisplayPreferencesController.cs56
-rw-r--r--Jellyfin.Api/Controllers/SyncPlayController.cs313
-rw-r--r--Jellyfin.Api/Controllers/TimeSyncController.cs14
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs42
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs14
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs16
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs30
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs22
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs24
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs14
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs37
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs24
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs32
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs42
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs25
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs14
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs24
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs16
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs16
-rw-r--r--Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs2
-rw-r--r--Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs90
-rw-r--r--Jellyfin.Data/Entities/DisplayPreferences.cs12
-rw-r--r--Jellyfin.Data/Jellyfin.Data.csproj2
-rw-r--r--Jellyfin.Server.Implementations/JellyfinDb.cs12
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs522
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs108
-rw-r--r--Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs139
-rw-r--r--Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs34
-rw-r--r--Jellyfin.Server/CoreAppHost.cs12
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs17
-rw-r--r--Jellyfin.Server/Migrations/MigrationRunner.cs3
-rw-r--r--Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs49
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs26
-rw-r--r--Jellyfin.Server/Startup.cs3
-rw-r--r--MediaBrowser.Controller/IDisplayPreferencesManager.cs21
-rw-r--r--MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs3
-rw-r--r--MediaBrowser.Controller/Net/IWebSocketListener.cs9
-rw-r--r--MediaBrowser.Controller/Net/IWebSocketManager.cs8
-rw-r--r--MediaBrowser.Controller/Session/ISessionManager.cs12
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupInfo.cs160
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupMember.cs27
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs222
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs126
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs165
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs168
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs680
-rw-r--r--MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs27
-rw-r--r--MediaBrowser.Controller/SyncPlay/IGroupState.cs217
-rw-r--r--MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs222
-rw-r--r--MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs67
-rw-r--r--MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs34
-rw-r--r--MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs16
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs29
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs61
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs36
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs45
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs37
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs21
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs36
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs54
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs37
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs46
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs61
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs38
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs36
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs37
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs36
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs36
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs21
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs21
-rw-r--r--MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs577
-rw-r--r--MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs29
-rw-r--r--MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs13
-rw-r--r--MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs13
-rw-r--r--MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs28
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupInfoDto.cs58
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupInfoView.cs42
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupQueueMode.cs18
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs23
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs18
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupStateType.cs28
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs31
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupUpdate.cs30
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupUpdateType.cs8
-rw-r--r--MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs16
-rw-r--r--MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs74
-rw-r--r--MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs58
-rw-r--r--MediaBrowser.Model/SyncPlay/PlaybackRequest.cs34
-rw-r--r--MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs71
-rw-r--r--MediaBrowser.Model/SyncPlay/QueueItem.cs31
-rw-r--r--MediaBrowser.Model/SyncPlay/RequestType.cs33
-rw-r--r--MediaBrowser.Model/SyncPlay/SendCommand.cs45
-rw-r--r--MediaBrowser.Model/SyncPlay/SendCommandType.cs11
-rw-r--r--MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs28
-rw-r--r--MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs21
106 files changed, 6499 insertions, 1346 deletions
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index edc8b0864..a63db6ed7 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -79,6 +79,7 @@
- [Nickbert7](https://github.com/Nickbert7)
- [nvllsvm](https://github.com/nvllsvm)
- [nyanmisaka](https://github.com/nyanmisaka)
+ - [OancaAndrei](https://github.com/OancaAndrei)
- [oddstr13](https://github.com/oddstr13)
- [orryverducci](https://github.com/orryverducci)
- [petermcneil](https://github.com/petermcneil)
diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
index 50c7a07d4..6e1f2feae 100644
--- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs
+++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
@@ -5037,13 +5037,6 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
var commandText = new StringBuilder("select Distinct p.Name from People p");
- if (query.User != null && query.IsFavorite.HasValue)
- {
- commandText.Append(" LEFT JOIN TypedBaseItems tbi ON tbi.Name=p.Name AND tbi.Type='");
- commandText.Append(typeof(Person).FullName);
- commandText.Append("' LEFT JOIN UserDatas ON tbi.UserDataKey=key AND userId=@UserId");
- }
-
var whereClauses = GetPeopleWhereClauses(query, null);
if (whereClauses.Count != 0)
@@ -5124,6 +5117,16 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
{
var whereClauses = new List<string>();
+ if (query.User != null && query.IsFavorite.HasValue)
+ {
+ whereClauses.Add(@"p.Name IN (
+SELECT Name FROM TypedBaseItems WHERE UserDataKey IN (
+SELECT key FROM UserDatas WHERE isFavorite=@IsFavorite AND userId=@UserId)
+AND Type = @InternalPersonType)");
+ statement?.TryBind("@IsFavorite", query.IsFavorite.Value);
+ statement?.TryBind("@InternalPersonType", typeof(Person).FullName);
+ }
+
if (!query.ItemId.Equals(Guid.Empty))
{
whereClauses.Add("ItemId=@ItemId");
@@ -5176,12 +5179,6 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
statement?.TryBind("@NameContains", "%" + query.NameContains + "%");
}
- if (query.IsFavorite.HasValue)
- {
- whereClauses.Add("isFavorite=@IsFavorite");
- statement?.TryBind("@IsFavorite", query.IsFavorite.Value);
- }
-
if (query.User != null)
{
statement?.TryBind("@UserId", query.User.InternalId);
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
index 71ece80a7..d6cf6233e 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
@@ -2,9 +2,9 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Net.WebSockets;
using System.Threading.Tasks;
-using Jellyfin.Data.Events;
using MediaBrowser.Controller.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
@@ -13,32 +13,23 @@ namespace Emby.Server.Implementations.HttpServer
{
public class WebSocketManager : IWebSocketManager
{
- private readonly Lazy<IEnumerable<IWebSocketListener>> _webSocketListeners;
+ private readonly IWebSocketListener[] _webSocketListeners;
private readonly ILogger<WebSocketManager> _logger;
private readonly ILoggerFactory _loggerFactory;
- private bool _disposed = false;
-
public WebSocketManager(
- Lazy<IEnumerable<IWebSocketListener>> webSocketListeners,
+ IEnumerable<IWebSocketListener> webSocketListeners,
ILogger<WebSocketManager> logger,
ILoggerFactory loggerFactory)
{
- _webSocketListeners = webSocketListeners;
+ _webSocketListeners = webSocketListeners.ToArray();
_logger = logger;
_loggerFactory = loggerFactory;
}
- public event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
-
/// <inheritdoc />
public async Task WebSocketRequestHandler(HttpContext context)
{
- if (_disposed)
- {
- return;
- }
-
try
{
_logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);
@@ -54,7 +45,13 @@ namespace Emby.Server.Implementations.HttpServer
OnReceive = ProcessWebSocketMessageReceived
};
- WebSocketConnected?.Invoke(this, new GenericEventArgs<IWebSocketConnection>(connection));
+ var tasks = new Task[_webSocketListeners.Length];
+ for (var i = 0; i < _webSocketListeners.Length; ++i)
+ {
+ tasks[i] = _webSocketListeners[i].ProcessWebSocketConnectedAsync(connection);
+ }
+
+ await Task.WhenAll(tasks).ConfigureAwait(false);
await connection.ProcessAsync().ConfigureAwait(false);
_logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress);
@@ -75,21 +72,13 @@ namespace Emby.Server.Implementations.HttpServer
/// <param name="result">The result.</param>
private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result)
{
- if (_disposed)
- {
- return Task.CompletedTask;
- }
-
- IEnumerable<Task> GetTasks()
+ var tasks = new Task[_webSocketListeners.Length];
+ for (var i = 0; i < _webSocketListeners.Length; ++i)
{
- var listeners = _webSocketListeners.Value;
- foreach (var x in listeners)
- {
- yield return x.ProcessMessageAsync(result);
- }
+ tasks[i] = _webSocketListeners[i].ProcessMessageAsync(result);
}
- return Task.WhenAll(GetTasks());
+ return Task.WhenAll(tasks);
}
}
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index afddfa856..b3965fcca 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/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index a5f847953..169eaefd8 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
@@ -4,7 +4,6 @@ using System.Linq;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
-using Jellyfin.Data.Events;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Net;
@@ -22,50 +21,48 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// The timeout in seconds after which a WebSocket is considered to be lost.
/// </summary>
- public const int WebSocketLostTimeout = 60;
+ private const int WebSocketLostTimeout = 60;
/// <summary>
/// The keep-alive interval factor; controls how often the watcher will check on the status of the WebSockets.
/// </summary>
- public const float IntervalFactor = 0.2f;
+ private const float IntervalFactor = 0.2f;
/// <summary>
/// The ForceKeepAlive factor; controls when a ForceKeepAlive is sent.
/// </summary>
- public const float ForceKeepAliveFactor = 0.75f;
+ private const float ForceKeepAliveFactor = 0.75f;
/// <summary>
- /// The _session manager.
+ /// Lock used for accesing the KeepAlive cancellation token.
/// </summary>
- private readonly ISessionManager _sessionManager;
+ private readonly object _keepAliveLock = new object();
/// <summary>
- /// The _logger.
+ /// The WebSocket watchlist.
/// </summary>
- private readonly ILogger<SessionWebSocketListener> _logger;
- private readonly ILoggerFactory _loggerFactory;
-
- private readonly IWebSocketManager _webSocketManager;
+ private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>();
/// <summary>
- /// The KeepAlive cancellation token.
+ /// Lock used for accessing the WebSockets watchlist.
/// </summary>
- private CancellationTokenSource _keepAliveCancellationToken;
+ private readonly object _webSocketsLock = new object();
/// <summary>
- /// Lock used for accesing the KeepAlive cancellation token.
+ /// The _session manager.
/// </summary>
- private readonly object _keepAliveLock = new object();
+ private readonly ISessionManager _sessionManager;
/// <summary>
- /// The WebSocket watchlist.
+ /// The _logger.
/// </summary>
- private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>();
+ private readonly ILogger<SessionWebSocketListener> _logger;
+ private readonly ILoggerFactory _loggerFactory;
/// <summary>
- /// Lock used for accesing the WebSockets watchlist.
+ /// The KeepAlive cancellation token.
/// </summary>
- private readonly object _webSocketsLock = new object();
+ private CancellationTokenSource _keepAliveCancellationToken;
/// <summary>
/// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class.
@@ -73,32 +70,42 @@ namespace Emby.Server.Implementations.Session
/// <param name="logger">The logger.</param>
/// <param name="sessionManager">The session manager.</param>
/// <param name="loggerFactory">The logger factory.</param>
- /// <param name="webSocketManager">The HTTP server.</param>
public SessionWebSocketListener(
ILogger<SessionWebSocketListener> logger,
ISessionManager sessionManager,
- ILoggerFactory loggerFactory,
- IWebSocketManager webSocketManager)
+ ILoggerFactory loggerFactory)
{
_logger = logger;
_sessionManager = sessionManager;
_loggerFactory = loggerFactory;
- _webSocketManager = webSocketManager;
+ }
- webSocketManager.WebSocketConnected += OnServerManagerWebSocketConnected;
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ StopKeepAlive();
}
- private async void OnServerManagerWebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e)
+ /// <summary>
+ /// Processes the message.
+ /// </summary>
+ /// <param name="message">The message.</param>
+ /// <returns>Task.</returns>
+ public Task ProcessMessageAsync(WebSocketMessageInfo message)
+ => Task.CompletedTask;
+
+ /// <inheritdoc />
+ public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection)
{
- var session = GetSession(e.Argument.QueryString, e.Argument.RemoteEndPoint.ToString());
+ var session = GetSession(connection.QueryString, connection.RemoteEndPoint.ToString());
if (session != null)
{
- EnsureController(session, e.Argument);
- await KeepAliveWebSocket(e.Argument).ConfigureAwait(false);
+ EnsureController(session, connection);
+ await KeepAliveWebSocket(connection).ConfigureAwait(false);
}
else
{
- _logger.LogWarning("Unable to determine session based on query string: {0}", e.Argument.QueryString);
+ _logger.LogWarning("Unable to determine session based on query string: {0}", connection.QueryString);
}
}
@@ -119,21 +126,6 @@ namespace Emby.Server.Implementations.Session
return _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint);
}
- /// <inheritdoc />
- public void Dispose()
- {
- _webSocketManager.WebSocketConnected -= OnServerManagerWebSocketConnected;
- StopKeepAlive();
- }
-
- /// <summary>
- /// Processes the message.
- /// </summary>
- /// <param name="message">The message.</param>
- /// <returns>Task.</returns>
- public Task ProcessMessageAsync(WebSocketMessageInfo message)
- => Task.CompletedTask;
-
private void EnsureController(SessionInfo session, IWebSocketConnection connection)
{
var controllerInfo = session.EnsureController<WebSocketController>(
diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs
new file mode 100644
index 000000000..7c2ad2477
--- /dev/null
+++ b/Emby.Server.Implementations/SyncPlay/Group.cs
@@ -0,0 +1,674 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.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.Controller.SyncPlay.Requests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.SyncPlay
+{
+ /// <summary>
+ /// Class Group.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public class Group : IGroupStateContext
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<Group> _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="Group" /> 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 Group(
+ ILoggerFactory loggerFactory,
+ IUserManager userManager,
+ ISessionManager sessionManager,
+ ILibraryManager libraryManager)
+ {
+ _loggerFactory = loggerFactory;
+ _userManager = userManager;
+ _sessionManager = sessionManager;
+ _libraryManager = libraryManager;
+ _logger = loggerFactory.CreateLogger<Group>();
+
+ _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 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 (!item.IsVisibleStandalone(user))
+ {
+ 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();
+ }
+
+ /// <summary>
+ /// Checks if the group is empty.
+ /// </summary>
+ /// <returns><c>true</c> if the group is empty, <c>false</c> otherwise.</returns>
+ public bool IsGroupEmpty() => _participants.Count == 0;
+
+ /// <summary>
+ /// Initializes the group with the session's info.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="request">The request.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ 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());
+ }
+
+ /// <summary>
+ /// Adds the session to the group.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="request">The request.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ 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());
+ }
+
+ /// <summary>
+ /// Removes the session from the group.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="request">The request.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ public void SessionLeave(SessionInfo session, LeaveGroupRequest request, 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());
+ }
+
+ /// <summary>
+ /// Handles the requested action by the session.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="request">The requested action.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ 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.Action, GroupId.ToString(), _state.Type);
+
+ // Apply requested changes to this group given its current state.
+ // Every request has a slightly different outcome depending on the group's state.
+ // There are currently four different group states that accomplish different goals:
+ // - Idle: in this state no media is playing and clients should be idle (playback is stopped).
+ // - Waiting: in this state the group is waiting for all the clients to be ready to start the playback,
+ // that is, they've either finished loading the media for the first time or they've finished buffering.
+ // Once all clients report to be ready the group's state can change to Playing or Paused.
+ // - Playing: clients have some media loaded and playback is unpaused.
+ // - Paused: clients have some media loaded but playback is currently paused.
+ request.Apply(this, _state, session, cancellationToken);
+ }
+
+ /// <summary>
+ /// Gets the info about the group for the clients.
+ /// </summary>
+ /// <returns>The group info for the clients.</returns>
+ public GroupInfoDto GetInfo()
+ {
+ var participants = _participants.Values.Select(session => session.Session.UserName).Distinct().ToList();
+ return new GroupInfoDto(GroupId, GroupName, _state.Type, participants, DateTime.UtcNow);
+ }
+
+ /// <summary>
+ /// Checks if a user has access to all content in the play queue.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <returns><c>true</c> if the user can access the play queue; <c>false</c> otherwise.</returns>
+ 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(Guid 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<Guid> 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(Guid 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 538479512..000000000
--- 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 7c4e00311..348213ee1 100644
--- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
+++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
@@ -1,13 +1,11 @@
using System;
+using System.Collections.Concurrent;
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;
using MediaBrowser.Controller.SyncPlay;
+using MediaBrowser.Controller.SyncPlay.Requests;
using MediaBrowser.Model.SyncPlay;
using Microsoft.Extensions.Logging;
@@ -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,18 +44,21 @@ 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 ConcurrentDictionary<string, Group> _sessionToGroupMap =
+ new ConcurrentDictionary<string, Group>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The groups.
/// </summary>
- private readonly Dictionary<Guid, ISyncPlayController> _groups =
- new Dictionary<Guid, ISyncPlayController>();
+ private readonly ConcurrentDictionary<Guid, Group> _groups =
+ new ConcurrentDictionary<Guid, Group>();
/// <summary>
- /// Lock used for accessing any group.
+ /// Lock used for accessing multiple groups at once.
/// </summary>
+ /// <remarks>
+ /// This lock has priority on locks made on <see cref="Group"/>.
+ /// </remarks>
private readonly object _groupsLock = new object();
private bool _disposed = false;
@@ -60,31 +66,24 @@ namespace Emby.Server.Implementations.SyncPlay
/// <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,286 +91,256 @@ 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)
+ if (session == null)
{
- return;
+ throw new InvalidOperationException("Session is null!");
}
- _sessionManager.SessionEnded -= OnSessionManagerSessionEnded;
- _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped;
-
- _disposed = true;
- }
-
- private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e)
- {
- var session = e.SessionInfo;
- if (!IsSessionInGroup(session))
+ if (request == null)
{
- return;
+ throw new InvalidOperationException("Request is null!");
}
- LeaveGroup(session, CancellationToken.None);
- }
-
- private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e)
- {
- var session = e.Session;
- if (!IsSessionInGroup(session))
+ // Locking required to access list of groups.
+ lock (_groupsLock)
{
- 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);
+ // Make sure that session has not joined another group.
+ if (_sessionToGroupMap.ContainsKey(session.Id))
+ {
+ var leaveGroupRequest = new LeaveGroupRequest();
+ LeaveGroup(session, leaveGroupRequest, cancellationToken);
+ }
- // Check ParentalRating access
- var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue
- || item.InheritedParentalRatingValue <= user.MaxParentalAgeRating;
+ var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager);
+ _groups[group.GroupId] = group;
- if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess)
- {
- var collections = _libraryManager.GetCollectionFolders(item).Select(
- folder => folder.Id.ToString("N", CultureInfo.InvariantCulture));
+ if (!_sessionToGroupMap.TryAdd(session.Id, group))
+ {
+ throw new InvalidOperationException("Could not add session to group!");
+ }
- return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any();
+ group.CreateGroup(session, request, cancellationToken);
}
-
- return hasParentalRatingAccess;
- }
-
- private Guid? GetSessionGroup(SessionInfo session)
- {
- _sessionToGroupMap.TryGetValue(session.Id, out var group);
- return group?.GetGroupId();
}
/// <inheritdoc />
- public void NewGroup(SessionInfo session, CancellationToken cancellationToken)
+ public void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
{
- var user = _userManager.GetUserById(session.UserId);
-
- if (user.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups)
+ if (session == null)
{
- _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;
+ throw new InvalidOperationException("Session is null!");
}
- lock (_groupsLock)
+ if (request == null)
{
- if (IsSessionInGroup(session))
- {
- LeaveGroup(session, cancellationToken);
- }
-
- var group = new SyncPlayController(_sessionManager, this);
- _groups[group.GetGroupId()] = group;
-
- group.CreateGroup(session, cancellationToken);
+ throw new InvalidOperationException("Request is null!");
}
- }
- /// <inheritdoc />
- public void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken)
- {
var user = _userManager.GetUserById(session.UserId);
- if (user.SyncPlayAccess == SyncPlayAccess.None)
- {
- _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;
- }
-
+ // Locking required to access list of groups.
lock (_groupsLock)
{
- ISyncPlayController group;
- _groups.TryGetValue(groupId, out group);
+ _groups.TryGetValue(request.GroupId, out Group group);
if (group == null)
{
- _logger.LogWarning("JoinGroup: {0} tried to join group {0} that does not exist.", session.Id, groupId);
+ _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, request.GroupId);
- var error = new GroupUpdate<string>()
- {
- Type = GroupUpdateType.GroupDoesNotExist
- };
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
+ var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.GroupDoesNotExist, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
return;
}
- if (!HasAccessToItem(user, group.GetPlayingItemId()))
+ // Group lock required to let other requests end first.
+ lock (group)
{
- _logger.LogWarning("JoinGroup: {0} does not have access to {1}.", session.Id, group.GetPlayingItemId());
+ 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>()
+ var error = new GroupUpdate<string>(group.GroupId, GroupUpdateType.LibraryAccessDenied, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
+ return;
+ }
+
+ if (_sessionToGroupMap.TryGetValue(session.Id, out var existingGroup))
{
- GroupId = group.GetGroupId().ToString(),
- Type = GroupUpdateType.LibraryAccessDenied
- };
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
- }
+ if (existingGroup.GroupId.Equals(request.GroupId))
+ {
+ // Restore session.
+ group.SessionJoin(session, request, cancellationToken);
+ return;
+ }
+
+ var leaveGroupRequest = new LeaveGroupRequest();
+ LeaveGroup(session, leaveGroupRequest, cancellationToken);
+ }
- if (IsSessionInGroup(session))
- {
- if (GetSessionGroup(session).Equals(groupId))
+ if (!_sessionToGroupMap.TryAdd(session.Id, group))
{
- return;
+ throw new InvalidOperationException("Could not add session to group!");
}
- LeaveGroup(session, cancellationToken);
+ group.SessionJoin(session, request, cancellationToken);
}
-
- group.SessionJoin(session, request, cancellationToken);
}
}
/// <inheritdoc />
- public void LeaveGroup(SessionInfo session, CancellationToken cancellationToken)
+ public void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken)
{
- // TODO: determine what happens to users that are in a group and get their permissions revoked
- lock (_groupsLock)
+ if (session == null)
{
- _sessionToGroupMap.TryGetValue(session.Id, out var group);
+ throw new InvalidOperationException("Session is null!");
+ }
- if (group == null)
- {
- _logger.LogWarning("LeaveGroup: {0} does not belong to any group.", session.Id);
+ if (request == null)
+ {
+ throw new InvalidOperationException("Request is null!");
+ }
- var error = new GroupUpdate<string>()
+ // Locking required to access list of groups.
+ lock (_groupsLock)
+ {
+ if (_sessionToGroupMap.TryGetValue(session.Id, out var group))
+ {
+ // Group lock required to let other requests end first.
+ lock (group)
{
- Type = GroupUpdateType.NotInGroup
- };
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
+ if (_sessionToGroupMap.TryRemove(session.Id, out var tempGroup))
+ {
+ if (!tempGroup.GroupId.Equals(group.GroupId))
+ {
+ throw new InvalidOperationException("Session was in wrong group!");
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException("Could not remove session from group!");
+ }
+
+ group.SessionLeave(session, request, cancellationToken);
+
+ if (group.IsGroupEmpty())
+ {
+ _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId);
+ _groups.Remove(group.GroupId, out _);
+ }
+ }
}
-
- group.SessionLeave(session, cancellationToken);
-
- if (group.IsGroupEmpty())
+ else
{
- _logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GetGroupId());
- _groups.Remove(group.GetGroupId(), out _);
+ _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id);
+
+ var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
+ return;
}
}
}
/// <inheritdoc />
- public List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId)
+ public List<GroupInfoDto> ListGroups(SessionInfo session, ListGroupsRequest request)
{
- var user = _userManager.GetUserById(session.UserId);
-
- if (user.SyncPlayAccess == SyncPlayAccess.None)
+ if (session == null)
{
- return new List<GroupInfoView>();
+ throw new InvalidOperationException("Session is null!");
}
- // Filter by item if requested
- if (!filterItemId.Equals(Guid.Empty))
+ if (request == null)
{
- return _groups.Values.Where(
- group => group.GetPlayingItemId().Equals(filterItemId) && HasAccessToItem(user, group.GetPlayingItemId())).Select(
- group => group.GetInfo()).ToList();
+ throw new InvalidOperationException("Request is null!");
}
- else
+
+ var user = _userManager.GetUserById(session.UserId);
+ List<GroupInfoDto> list = new List<GroupInfoDto>();
+
+ foreach (var group in _groups.Values)
{
- // Otherwise show all available groups
- return _groups.Values.Where(
- group => HasAccessToItem(user, group.GetPlayingItemId())).Select(
- group => group.GetInfo()).ToList();
+ // Locking required as group is not thread-safe.
+ lock (group)
+ {
+ if (group.HasAccessToPlayQueue(user))
+ {
+ list.Add(group.GetInfo());
+ }
+ }
}
+
+ return list;
}
/// <inheritdoc />
- public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
+ public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken)
{
- var user = _userManager.GetUserById(session.UserId);
-
- if (user.SyncPlayAccess == SyncPlayAccess.None)
+ if (session == null)
{
- _logger.LogWarning("HandleRequest: {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;
+ throw new InvalidOperationException("Session is null!");
}
- lock (_groupsLock)
+ if (request == null)
{
- _sessionToGroupMap.TryGetValue(session.Id, out var group);
+ throw new InvalidOperationException("Request is null!");
+ }
- if (group == null)
+ if (_sessionToGroupMap.TryGetValue(session.Id, out var group))
+ {
+ // Group lock required as Group is not thread-safe.
+ lock (group)
{
- _logger.LogWarning("HandleRequest: {0} does not belong to any group.", session.Id);
+ // Make sure that session still belongs to this group.
+ if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId))
+ {
+ // Drop request.
+ return;
+ }
- var error = new GroupUpdate<string>()
+ // Drop request if group is empty.
+ if (group.IsGroupEmpty())
{
- Type = GroupUpdateType.NotInGroup
- };
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
+ return;
+ }
+
+ // Apply requested changes to group.
+ group.HandleRequest(session, request, cancellationToken);
}
+ }
+ else
+ {
+ _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id);
- group.HandleRequest(session, request, cancellationToken);
+ var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
}
}
- /// <inheritdoc />
- public void AddSessionToGroup(SessionInfo session, ISyncPlayController group)
+ /// <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 (IsSessionInGroup(session))
+ if (_disposed)
{
- throw new InvalidOperationException("Session in other group already!");
+ return;
}
- _sessionToGroupMap[session.Id] = group;
+ _sessionManager.SessionStarted -= OnSessionManagerSessionStarted;
+ _disposed = true;
}
- /// <inheritdoc />
- public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group)
+ private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e)
{
- if (!IsSessionInGroup(session))
- {
- throw new InvalidOperationException("Session not in any group!");
- }
+ var session = e.SessionInfo;
- _sessionToGroupMap.Remove(session.Id, out var tempGroup);
- if (!tempGroup.GetGroupId().Equals(group.GetGroupId()))
+ if (_sessionToGroupMap.TryGetValue(session.Id, out var group))
{
- throw new InvalidOperationException("Session was in wrong group!");
+ var request = new JoinGroupRequest(group.GroupId);
+ JoinGroup(session, request, CancellationToken.None);
}
}
}
diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
new file mode 100644
index 000000000..b5932ea6b
--- /dev/null
+++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
@@ -0,0 +1,58 @@
+using System.Threading.Tasks;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
+{
+ /// <summary>
+ /// Default authorization handler.
+ /// </summary>
+ public class SyncPlayAccessHandler : BaseAuthorizationHandler<SyncPlayAccessRequirement>
+ {
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SyncPlayAccessHandler"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+ /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+ public SyncPlayAccessHandler(
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ : base(userManager, networkManager, httpContextAccessor)
+ {
+ _userManager = userManager;
+ }
+
+ /// <inheritdoc />
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement)
+ {
+ if (!ValidateClaims(context.User))
+ {
+ context.Fail();
+ return Task.CompletedTask;
+ }
+
+ var userId = ClaimHelpers.GetUserId(context.User);
+ var user = _userManager.GetUserById(userId!.Value);
+
+ if ((requirement.RequiredAccess.HasValue && user.SyncPlayAccess == requirement.RequiredAccess)
+ || user.SyncPlayAccess == SyncPlayAccess.CreateAndJoinGroups)
+ {
+ context.Succeed(requirement);
+ }
+ else
+ {
+ context.Fail();
+ }
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs
new file mode 100644
index 000000000..7fcaf69f6
--- /dev/null
+++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs
@@ -0,0 +1,33 @@
+using Jellyfin.Data.Enums;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
+{
+ /// <summary>
+ /// The default authorization requirement.
+ /// </summary>
+ public class SyncPlayAccessRequirement : IAuthorizationRequirement
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.
+ /// </summary>
+ /// <param name="requiredAccess">A value of <see cref="SyncPlayAccess"/>.</param>
+ public SyncPlayAccessRequirement(SyncPlayAccess requiredAccess)
+ {
+ RequiredAccess = requiredAccess;
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.
+ /// </summary>
+ public SyncPlayAccessRequirement()
+ {
+ RequiredAccess = null;
+ }
+
+ /// <summary>
+ /// Gets the required SyncPlay access.
+ /// </summary>
+ public SyncPlayAccess? RequiredAccess { get; }
+ }
+}
diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs
index 7d7767470..b35ceea1a 100644
--- a/Jellyfin.Api/Constants/Policies.cs
+++ b/Jellyfin.Api/Constants/Policies.cs
@@ -49,5 +49,15 @@ namespace Jellyfin.Api.Constants
/// Policy name for escaping schedule controls or requiring first time setup.
/// </summary>
public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl";
+
+ /// <summary>
+ /// Policy name for requiring access to SyncPlay.
+ /// </summary>
+ public const string SyncPlayAccess = "SyncPlayAccess";
+
+ /// <summary>
+ /// Policy name for requiring group creation access to SyncPlay.
+ /// </summary>
+ public const string SyncPlayCreateGroupAccess = "SyncPlayCreateGroupAccess";
}
}
diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
index 76f5717e3..8b8f63015 100644
--- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
+++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
@@ -6,6 +6,7 @@ using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authorization;
@@ -47,13 +48,19 @@ namespace Jellyfin.Api.Controllers
[FromQuery, Required] Guid userId,
[FromQuery, Required] string client)
{
- var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client);
- var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client);
+ if (!Guid.TryParse(displayPreferencesId, out var itemId))
+ {
+ itemId = displayPreferencesId.GetMD5();
+ }
+
+ var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
+ var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
+ itemPreferences.ItemId = itemId;
var dto = new DisplayPreferencesDto
{
Client = displayPreferences.Client,
- Id = displayPreferences.UserId.ToString(),
+ Id = displayPreferences.ItemId.ToString(),
ViewType = itemPreferences.ViewType.ToString(),
SortBy = itemPreferences.SortBy,
SortOrder = itemPreferences.SortOrder,
@@ -81,6 +88,16 @@ namespace Jellyfin.Api.Controllers
dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
+ // Load all custom display preferences
+ var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
+ if (customDisplayPreferences != null)
+ {
+ foreach (var (key, value) in customDisplayPreferences)
+ {
+ dto.CustomPrefs.TryAdd(key, value);
+ }
+ }
+
// This will essentially be a noop if no changes have been made, but new prefs must be saved at least.
_displayPreferencesManager.SaveChanges();
@@ -115,7 +132,12 @@ namespace Jellyfin.Api.Controllers
HomeSectionType.LatestMedia, HomeSectionType.None,
};
- var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client);
+ if (!Guid.TryParse(displayPreferencesId, out var itemId))
+ {
+ itemId = displayPreferencesId.GetMD5();
+ }
+
+ var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null;
existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop;
existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar;
@@ -124,21 +146,33 @@ namespace Jellyfin.Api.Controllers
existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion)
? Enum.Parse<ChromecastVersion>(chromecastVersion, true)
: ChromecastVersion.Stable;
+ displayPreferences.CustomPrefs.Remove("chromecastVersion");
+
existingDisplayPreferences.EnableNextVideoInfoOverlay = displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay)
? bool.Parse(enableNextVideoInfoOverlay)
: true;
+ displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay");
+
existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength)
? int.Parse(skipBackLength, CultureInfo.InvariantCulture)
: 10000;
+ displayPreferences.CustomPrefs.Remove("skipBackLength");
+
existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength)
? int.Parse(skipForwardLength, CultureInfo.InvariantCulture)
: 30000;
+ displayPreferences.CustomPrefs.Remove("skipForwardLength");
+
existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme)
? theme
: string.Empty;
+ displayPreferences.CustomPrefs.Remove("dashboardTheme");
+
existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home)
? home
: string.Empty;
+ displayPreferences.CustomPrefs.Remove("tvhome");
+
existingDisplayPreferences.HomeSections.Clear();
foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase)))
@@ -149,26 +183,34 @@ namespace Jellyfin.Api.Controllers
type = order < 7 ? defaults[order] : HomeSectionType.None;
}
+ displayPreferences.CustomPrefs.Remove(key);
existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type });
}
foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
{
- var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Parse(key.Substring("landing-".Length)), existingDisplayPreferences.Client);
- itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType);
+ if (Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var preferenceId))
+ {
+ var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, preferenceId, existingDisplayPreferences.Client);
+ itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType);
+ displayPreferences.CustomPrefs.Remove(key);
+ }
}
- var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Empty, existingDisplayPreferences.Client);
+ var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client);
itemPrefs.SortBy = displayPreferences.SortBy;
itemPrefs.SortOrder = displayPreferences.SortOrder;
itemPrefs.RememberIndexing = displayPreferences.RememberIndexing;
itemPrefs.RememberSorting = displayPreferences.RememberSorting;
+ itemPrefs.ItemId = itemId;
if (Enum.TryParse<ViewType>(displayPreferences.ViewType, true, out var viewType))
{
itemPrefs.ViewType = viewType;
}
+ // Set all remaining custom preferences.
+ _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
_displayPreferencesManager.SaveChanges();
return NoContent();
diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs
index 346431e60..471c9180d 100644
--- a/Jellyfin.Api/Controllers/SyncPlayController.cs
+++ b/Jellyfin.Api/Controllers/SyncPlayController.cs
@@ -4,9 +4,12 @@ using System.ComponentModel.DataAnnotations;
using System.Threading;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.SyncPlayDtos;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Controller.SyncPlay;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Controller.SyncPlay.Requests;
using MediaBrowser.Model.SyncPlay;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -17,7 +20,7 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// The sync play controller.
/// </summary>
- [Authorize(Policy = Policies.DefaultAuthorization)]
+ [Authorize(Policy = Policies.SyncPlayAccess)]
public class SyncPlayController : BaseJellyfinApiController
{
private readonly ISessionManager _sessionManager;
@@ -43,35 +46,36 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Create a new SyncPlay group.
/// </summary>
+ /// <param name="requestData">The settings of the new group.</param>
/// <response code="204">New group created.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("New")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SyncPlayCreateGroup()
+ [Authorize(Policy = Policies.SyncPlayCreateGroupAccess)]
+ public ActionResult SyncPlayCreateGroup(
+ [FromBody, Required] NewGroupRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- _syncPlayManager.NewGroup(currentSession, CancellationToken.None);
+ var syncPlayRequest = new NewGroupRequest(requestData.GroupName);
+ _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
/// <summary>
/// Join an existing SyncPlay group.
/// </summary>
- /// <param name="groupId">The sync play group id.</param>
+ /// <param name="requestData">The group to join.</param>
/// <response code="204">Group join successful.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Join")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SyncPlayJoinGroup([FromQuery, Required] Guid groupId)
+ [Authorize(Policy = Policies.SyncPlayAccess)]
+ public ActionResult SyncPlayJoinGroup(
+ [FromBody, Required] JoinGroupRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
-
- var joinRequest = new JoinGroupRequest()
- {
- GroupId = groupId
- };
-
- _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None);
+ var syncPlayRequest = new JoinGroupRequest(requestData.GroupId);
+ _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
@@ -85,38 +89,125 @@ namespace Jellyfin.Api.Controllers
public ActionResult SyncPlayLeaveGroup()
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- _syncPlayManager.LeaveGroup(currentSession, CancellationToken.None);
+ var syncPlayRequest = new LeaveGroupRequest();
+ _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
/// <summary>
/// Gets all SyncPlay groups.
/// </summary>
- /// <param name="filterItemId">Optional. Filter by item id.</param>
/// <response code="200">Groups returned.</response>
/// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns>
[HttpGet("List")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<IEnumerable<GroupInfoView>> SyncPlayGetGroups([FromQuery] Guid? filterItemId)
+ [Authorize(Policy = Policies.SyncPlayAccess)]
+ public ActionResult<IEnumerable<GroupInfoDto>> SyncPlayGetGroups()
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new ListGroupsRequest();
+ return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest));
+ }
+
+ /// <summary>
+ /// Request to set new playlist in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The new playlist to play in the group.</param>
+ /// <response code="204">Queue update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("SetNewQueue")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlaySetNewQueue(
+ [FromBody, Required] PlayRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- return Ok(_syncPlayManager.ListGroups(currentSession, filterItemId.HasValue ? filterItemId.Value : Guid.Empty));
+ var syncPlayRequest = new PlayGroupRequest(
+ requestData.PlayingQueue,
+ requestData.PlayingItemPosition,
+ requestData.StartPositionTicks);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
}
/// <summary>
- /// Request play in SyncPlay group.
+ /// Request to change playlist item in SyncPlay group.
/// </summary>
- /// <response code="204">Play request sent to all group members.</response>
+ /// <param name="requestData">The new item to play.</param>
+ /// <response code="204">Queue update sent to all group members.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
- [HttpPost("Play")]
+ [HttpPost("SetPlaylistItem")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SyncPlayPlay()
+ public ActionResult SyncPlaySetPlaylistItem(
+ [FromBody, Required] SetPlaylistItemRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- var syncPlayRequest = new PlaybackRequest()
- {
- Type = PlaybackRequestType.Play
- };
+ var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request to remove items from the playlist in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The items to remove.</param>
+ /// <response code="204">Queue update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("RemoveFromPlaylist")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayRemoveFromPlaylist(
+ [FromBody, Required] RemoveFromPlaylistRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request to move an item in the playlist in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The new position for the item.</param>
+ /// <response code="204">Queue update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("MovePlaylistItem")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayMovePlaylistItem(
+ [FromBody, Required] MovePlaylistItemRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request to queue items to the playlist of a SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The items to add.</param>
+ /// <response code="204">Queue update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("Queue")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayQueue(
+ [FromBody, Required] QueueRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request unpause in SyncPlay group.
+ /// </summary>
+ /// <response code="204">Unpause update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("Unpause")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayUnpause()
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new UnpauseGroupRequest();
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
@@ -124,17 +215,29 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Request pause in SyncPlay group.
/// </summary>
- /// <response code="204">Pause request sent to all group members.</response>
+ /// <response code="204">Pause update sent to all group members.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Pause")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SyncPlayPause()
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- var syncPlayRequest = new PlaybackRequest()
- {
- Type = PlaybackRequestType.Pause
- };
+ var syncPlayRequest = new PauseGroupRequest();
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request stop in SyncPlay group.
+ /// </summary>
+ /// <response code="204">Stop update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("Stop")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayStop()
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new StopGroupRequest();
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
@@ -142,42 +245,143 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Request seek in SyncPlay group.
/// </summary>
- /// <param name="positionTicks">The playback position in ticks.</param>
- /// <response code="204">Seek request sent to all group members.</response>
+ /// <param name="requestData">The new playback position.</param>
+ /// <response code="204">Seek update sent to all group members.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Seek")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SyncPlaySeek([FromQuery] long positionTicks)
+ public ActionResult SyncPlaySeek(
+ [FromBody, Required] SeekRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- var syncPlayRequest = new PlaybackRequest()
- {
- Type = PlaybackRequestType.Seek,
- PositionTicks = positionTicks
- };
+ var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks);
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
/// <summary>
- /// Request group wait in SyncPlay group while buffering.
+ /// Notify SyncPlay group that member is buffering.
/// </summary>
- /// <param name="when">When the request has been made by the client.</param>
- /// <param name="positionTicks">The playback position in ticks.</param>
- /// <param name="bufferingDone">Whether the buffering is done.</param>
- /// <response code="204">Buffering request sent to all group members.</response>
+ /// <param name="requestData">The player status.</param>
+ /// <response code="204">Group state update sent to all group members.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Buffering")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SyncPlayBuffering([FromQuery] DateTime when, [FromQuery] long positionTicks, [FromQuery] bool bufferingDone)
+ public ActionResult SyncPlayBuffering(
+ [FromBody, Required] BufferRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new BufferGroupRequest(
+ requestData.When,
+ requestData.PositionTicks,
+ requestData.IsPlaying,
+ requestData.PlaylistItemId);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Notify SyncPlay group that member is ready for playback.
+ /// </summary>
+ /// <param name="requestData">The player status.</param>
+ /// <response code="204">Group state update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("Ready")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayReady(
+ [FromBody, Required] ReadyRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new ReadyGroupRequest(
+ requestData.When,
+ requestData.PositionTicks,
+ requestData.IsPlaying,
+ requestData.PlaylistItemId);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request SyncPlay group to ignore member during group-wait.
+ /// </summary>
+ /// <param name="requestData">The settings to set.</param>
+ /// <response code="204">Member state updated.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("SetIgnoreWait")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlaySetIgnoreWait(
+ [FromBody, Required] IgnoreWaitRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request next item in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The current item information.</param>
+ /// <response code="204">Next item update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("NextItem")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayNextItem(
+ [FromBody, Required] NextItemRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request previous item in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The current item information.</param>
+ /// <response code="204">Previous item update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("PreviousItem")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayPreviousItem(
+ [FromBody, Required] PreviousItemRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request to set repeat mode in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The new repeat mode.</param>
+ /// <response code="204">Play queue update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("SetRepeatMode")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlaySetRepeatMode(
+ [FromBody, Required] SetRepeatModeRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request to set shuffle mode in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The new shuffle mode.</param>
+ /// <response code="204">Play queue update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("SetShuffleMode")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlaySetShuffleMode(
+ [FromBody, Required] SetShuffleModeRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- var syncPlayRequest = new PlaybackRequest()
- {
- Type = bufferingDone ? PlaybackRequestType.Ready : PlaybackRequestType.Buffer,
- When = when,
- PositionTicks = positionTicks
- };
+ var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode);
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
@@ -185,19 +389,16 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Update session ping.
/// </summary>
- /// <param name="ping">The ping.</param>
+ /// <param name="requestData">The new ping.</param>
/// <response code="204">Ping updated.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Ping")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SyncPlayPing([FromQuery] double ping)
+ public ActionResult SyncPlayPing(
+ [FromBody, Required] PingRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- var syncPlayRequest = new PlaybackRequest()
- {
- Type = PlaybackRequestType.Ping,
- Ping = Convert.ToInt64(ping)
- };
+ var syncPlayRequest = new PingGroupRequest(requestData.Ping);
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs
index 27c7186fc..c730ac12b 100644
--- a/Jellyfin.Api/Controllers/TimeSyncController.cs
+++ b/Jellyfin.Api/Controllers/TimeSyncController.cs
@@ -13,7 +13,7 @@ namespace Jellyfin.Api.Controllers
public class TimeSyncController : BaseJellyfinApiController
{
/// <summary>
- /// Gets the current utc time.
+ /// Gets the current UTC time.
/// </summary>
/// <response code="200">Time returned.</response>
/// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns>
@@ -22,18 +22,14 @@ namespace Jellyfin.Api.Controllers
public ActionResult<UtcTimeResponse> GetUtcTime()
{
// Important to keep the following line at the beginning
- var requestReceptionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo);
+ var requestReceptionTime = DateTime.UtcNow.ToUniversalTime();
- var response = new UtcTimeResponse();
- response.RequestReceptionTime = requestReceptionTime;
-
- // Important to keep the following two lines at the end
- var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo);
- response.ResponseTransmissionTime = responseTransmissionTime;
+ // Important to keep the following line at the end
+ var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime();
// Implementing NTP on such a high level results in this useless
// information being sent. On the other hand it enables future additions.
- return response;
+ return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime);
}
}
}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs
new file mode 100644
index 000000000..479c44084
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs
@@ -0,0 +1,42 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class BufferRequestDto.
+ /// </summary>
+ public class BufferRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BufferRequestDto"/> class.
+ /// </summary>
+ public BufferRequestDto()
+ {
+ PlaylistItemId = Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets when the request has been made by the client.
+ /// </summary>
+ /// <value>The date of the request.</value>
+ public DateTime When { get; set; }
+
+ /// <summary>
+ /// Gets or sets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the client playback is unpaused.
+ /// </summary>
+ /// <value>The client playback status.</value>
+ public bool IsPlaying { get; set; }
+
+ /// <summary>
+ /// Gets or sets the playlist item identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist item identifier.</value>
+ public Guid PlaylistItemId { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs
new file mode 100644
index 000000000..4c30b7be4
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs
@@ -0,0 +1,14 @@
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class IgnoreWaitRequestDto.
+ /// </summary>
+ public class IgnoreWaitRequestDto
+ {
+ /// <summary>
+ /// Gets or sets a value indicating whether the client should be ignored.
+ /// </summary>
+ /// <value>The client group-wait status.</value>
+ public bool IgnoreWait { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs
new file mode 100644
index 000000000..ed97b8d6a
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class JoinGroupRequestDto.
+ /// </summary>
+ public class JoinGroupRequestDto
+ {
+ /// <summary>
+ /// Gets or sets the group identifier.
+ /// </summary>
+ /// <value>The identifier of the group to join.</value>
+ public Guid GroupId { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs
new file mode 100644
index 000000000..3af25f3e3
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs
@@ -0,0 +1,30 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class MovePlaylistItemRequestDto.
+ /// </summary>
+ public class MovePlaylistItemRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MovePlaylistItemRequestDto"/> class.
+ /// </summary>
+ public MovePlaylistItemRequestDto()
+ {
+ PlaylistItemId = Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets the playlist identifier of the item.
+ /// </summary>
+ /// <value>The playlist identifier of the item.</value>
+ public Guid PlaylistItemId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the new position.
+ /// </summary>
+ /// <value>The new position.</value>
+ public int NewIndex { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs
new file mode 100644
index 000000000..441d7be36
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs
@@ -0,0 +1,22 @@
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class NewGroupRequestDto.
+ /// </summary>
+ public class NewGroupRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NewGroupRequestDto"/> class.
+ /// </summary>
+ public NewGroupRequestDto()
+ {
+ GroupName = string.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets the group name.
+ /// </summary>
+ /// <value>The name of the new group.</value>
+ public string GroupName { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs
new file mode 100644
index 000000000..f59a93f13
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class NextItemRequestDto.
+ /// </summary>
+ public class NextItemRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NextItemRequestDto"/> class.
+ /// </summary>
+ public NextItemRequestDto()
+ {
+ PlaylistItemId = Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets the playing item identifier.
+ /// </summary>
+ /// <value>The playing item identifier.</value>
+ public Guid PlaylistItemId { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs
new file mode 100644
index 000000000..c4ac06856
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs
@@ -0,0 +1,14 @@
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class PingRequestDto.
+ /// </summary>
+ public class PingRequestDto
+ {
+ /// <summary>
+ /// Gets or sets the ping time.
+ /// </summary>
+ /// <value>The ping time.</value>
+ public long Ping { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs
new file mode 100644
index 000000000..844388cd9
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class PlayRequestDto.
+ /// </summary>
+ public class PlayRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlayRequestDto"/> class.
+ /// </summary>
+ public PlayRequestDto()
+ {
+ PlayingQueue = Array.Empty<Guid>();
+ }
+
+ /// <summary>
+ /// Gets or sets the playing queue.
+ /// </summary>
+ /// <value>The playing queue.</value>
+ public IReadOnlyList<Guid> PlayingQueue { get; set; }
+
+ /// <summary>
+ /// Gets or sets the position of the playing item in the queue.
+ /// </summary>
+ /// <value>The playing item position.</value>
+ public int PlayingItemPosition { get; set; }
+
+ /// <summary>
+ /// Gets or sets the start position ticks.
+ /// </summary>
+ /// <value>The start position ticks.</value>
+ public long StartPositionTicks { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs
new file mode 100644
index 000000000..7fd4a49be
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class PreviousItemRequestDto.
+ /// </summary>
+ public class PreviousItemRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PreviousItemRequestDto"/> class.
+ /// </summary>
+ public PreviousItemRequestDto()
+ {
+ PlaylistItemId = Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets the playing item identifier.
+ /// </summary>
+ /// <value>The playing item identifier.</value>
+ public Guid PlaylistItemId { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs
new file mode 100644
index 000000000..2b187f443
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Model.SyncPlay;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class QueueRequestDto.
+ /// </summary>
+ public class QueueRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="QueueRequestDto"/> class.
+ /// </summary>
+ public QueueRequestDto()
+ {
+ ItemIds = Array.Empty<Guid>();
+ }
+
+ /// <summary>
+ /// Gets or sets the items to enqueue.
+ /// </summary>
+ /// <value>The items to enqueue.</value>
+ public IReadOnlyList<Guid> ItemIds { get; set; }
+
+ /// <summary>
+ /// Gets or sets the mode in which to add the new items.
+ /// </summary>
+ /// <value>The enqueue mode.</value>
+ public GroupQueueMode Mode { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs
new file mode 100644
index 000000000..d9c193016
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs
@@ -0,0 +1,42 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class ReadyRequest.
+ /// </summary>
+ public class ReadyRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ReadyRequestDto"/> class.
+ /// </summary>
+ public ReadyRequestDto()
+ {
+ PlaylistItemId = Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets when the request has been made by the client.
+ /// </summary>
+ /// <value>The date of the request.</value>
+ public DateTime When { get; set; }
+
+ /// <summary>
+ /// Gets or sets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the client playback is unpaused.
+ /// </summary>
+ /// <value>The client playback status.</value>
+ public bool IsPlaying { get; set; }
+
+ /// <summary>
+ /// Gets or sets the playlist item identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist item identifier.</value>
+ public Guid PlaylistItemId { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs
new file mode 100644
index 000000000..e9b2b2cb3
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class RemoveFromPlaylistRequestDto.
+ /// </summary>
+ public class RemoveFromPlaylistRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RemoveFromPlaylistRequestDto"/> class.
+ /// </summary>
+ public RemoveFromPlaylistRequestDto()
+ {
+ PlaylistItemIds = Array.Empty<Guid>();
+ }
+
+ /// <summary>
+ /// Gets or sets the playlist identifiers ot the items.
+ /// </summary>
+ /// <value>The playlist identifiers ot the items.</value>
+ public IReadOnlyList<Guid> PlaylistItemIds { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs
new file mode 100644
index 000000000..b9af0be7f
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs
@@ -0,0 +1,14 @@
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class SeekRequestDto.
+ /// </summary>
+ public class SeekRequestDto
+ {
+ /// <summary>
+ /// Gets or sets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs
new file mode 100644
index 000000000..b937679fc
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class SetPlaylistItemRequestDto.
+ /// </summary>
+ public class SetPlaylistItemRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SetPlaylistItemRequestDto"/> class.
+ /// </summary>
+ public SetPlaylistItemRequestDto()
+ {
+ PlaylistItemId = Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets the playlist identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist identifier of the playing item.</value>
+ public Guid PlaylistItemId { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs
new file mode 100644
index 000000000..e748fc3e0
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs
@@ -0,0 +1,16 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class SetRepeatModeRequestDto.
+ /// </summary>
+ public class SetRepeatModeRequestDto
+ {
+ /// <summary>
+ /// Gets or sets the repeat mode.
+ /// </summary>
+ /// <value>The repeat mode.</value>
+ public GroupRepeatMode Mode { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs
new file mode 100644
index 000000000..0e427f4a4
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs
@@ -0,0 +1,16 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class SetShuffleModeRequestDto.
+ /// </summary>
+ public class SetShuffleModeRequestDto
+ {
+ /// <summary>
+ /// Gets or sets the shuffle mode.
+ /// </summary>
+ /// <value>The shuffle mode.</value>
+ public GroupShuffleMode Mode { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs
index ce5465116..288e03fcf 100644
--- a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs
+++ b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs
@@ -58,7 +58,7 @@ namespace Jellyfin.Api.WebSocketListeners
private void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e)
{
- SendData(true);
+ SendData(true).GetAwaiter().GetResult();
}
}
}
diff --git a/Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs b/Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs
new file mode 100644
index 000000000..511e3b281
--- /dev/null
+++ b/Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs
@@ -0,0 +1,90 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Jellyfin.Data.Entities
+{
+ /// <summary>
+ /// An entity that represents a user's custom display preferences for a specific item.
+ /// </summary>
+ public class CustomItemDisplayPreferences
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CustomItemDisplayPreferences"/> class.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="client">The client.</param>
+ /// <param name="preferenceKey">The preference key.</param>
+ /// <param name="preferenceValue">The preference value.</param>
+ public CustomItemDisplayPreferences(Guid userId, Guid itemId, string client, string preferenceKey, string preferenceValue)
+ {
+ UserId = userId;
+ ItemId = itemId;
+ Client = client;
+ Key = preferenceKey;
+ Value = preferenceValue;
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CustomItemDisplayPreferences"/> class.
+ /// </summary>
+ protected CustomItemDisplayPreferences()
+ {
+ }
+
+ /// <summary>
+ /// Gets or sets the Id.
+ /// </summary>
+ /// <remarks>
+ /// Required.
+ /// </remarks>
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int Id { get; protected set; }
+
+ /// <summary>
+ /// Gets or sets the user Id.
+ /// </summary>
+ /// <remarks>
+ /// Required.
+ /// </remarks>
+ public Guid UserId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the id of the associated item.
+ /// </summary>
+ /// <remarks>
+ /// Required.
+ /// </remarks>
+ public Guid ItemId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the client string.
+ /// </summary>
+ /// <remarks>
+ /// Required. Max Length = 32.
+ /// </remarks>
+ [Required]
+ [MaxLength(32)]
+ [StringLength(32)]
+ public string Client { get; set; }
+
+ /// <summary>
+ /// Gets or sets the preference key.
+ /// </summary>
+ /// <remarks>
+ /// Required.
+ /// </remarks>
+ [Required]
+ public string Key { get; set; }
+
+ /// <summary>
+ /// Gets or sets the preference value.
+ /// </summary>
+ /// <remarks>
+ /// Required.
+ /// </remarks>
+ [Required]
+ public string Value { get; set; }
+ }
+}
diff --git a/Jellyfin.Data/Entities/DisplayPreferences.cs b/Jellyfin.Data/Entities/DisplayPreferences.cs
index 701e4df00..1a8ca1da3 100644
--- a/Jellyfin.Data/Entities/DisplayPreferences.cs
+++ b/Jellyfin.Data/Entities/DisplayPreferences.cs
@@ -17,10 +17,12 @@ namespace Jellyfin.Data.Entities
/// Initializes a new instance of the <see cref="DisplayPreferences"/> class.
/// </summary>
/// <param name="userId">The user's id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="client">The client string.</param>
- public DisplayPreferences(Guid userId, string client)
+ public DisplayPreferences(Guid userId, Guid itemId, string client)
{
UserId = userId;
+ ItemId = itemId;
Client = client;
ShowSidebar = false;
ShowBackdrop = true;
@@ -59,6 +61,14 @@ namespace Jellyfin.Data.Entities
public Guid UserId { get; set; }
/// <summary>
+ /// Gets or sets the id of the associated item.
+ /// </summary>
+ /// <remarks>
+ /// Required.
+ /// </remarks>
+ public Guid ItemId { get; set; }
+
+ /// <summary>
/// Gets or sets the client string.
/// </summary>
/// <remarks>
diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj
index 9ae129d07..89d6f4d9b 100644
--- a/Jellyfin.Data/Jellyfin.Data.csproj
+++ b/Jellyfin.Data/Jellyfin.Data.csproj
@@ -29,7 +29,7 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/>
+ <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
</ItemGroup>
<!-- Code analysers-->
diff --git a/Jellyfin.Server.Implementations/JellyfinDb.cs b/Jellyfin.Server.Implementations/JellyfinDb.cs
index bf8818f8d..7f3f83749 100644
--- a/Jellyfin.Server.Implementations/JellyfinDb.cs
+++ b/Jellyfin.Server.Implementations/JellyfinDb.cs
@@ -34,6 +34,8 @@ namespace Jellyfin.Server.Implementations
public virtual DbSet<ItemDisplayPreferences> ItemDisplayPreferences { get; set; }
+ public virtual DbSet<CustomItemDisplayPreferences> CustomItemDisplayPreferences { get; set; }
+
public virtual DbSet<Permission> Permissions { get; set; }
public virtual DbSet<Preference> Preferences { get; set; }
@@ -151,7 +153,15 @@ namespace Jellyfin.Server.Implementations
.IsUnique(false);
modelBuilder.Entity<DisplayPreferences>()
- .HasIndex(entity => new { entity.UserId, entity.Client })
+ .HasIndex(entity => new { entity.UserId, entity.ItemId, entity.Client })
+ .IsUnique();
+
+ modelBuilder.Entity<CustomItemDisplayPreferences>()
+ .HasIndex(entity => entity.UserId)
+ .IsUnique(false);
+
+ modelBuilder.Entity<CustomItemDisplayPreferences>()
+ .HasIndex(entity => new { entity.UserId, entity.ItemId, entity.Client, entity.Key })
.IsUnique();
}
}
diff --git a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs
new file mode 100644
index 000000000..10663d065
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs
@@ -0,0 +1,522 @@
+#pragma warning disable CS1591
+// <auto-generated />
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ [DbContext(typeof(JellyfinDb))]
+ [Migration("20201204223655_AddCustomDisplayPreferences")]
+ partial class AddCustomDisplayPreferences
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("jellyfin")
+ .HasAnnotation("ProductVersion", "5.0.0");
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("ActivityLogs");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Permission_Permissions_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Permission_Permissions_Guid");
+
+ b.ToTable("Permissions");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Preference_Preferences_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Preference_Preferences_Guid");
+
+ b.ToTable("Preferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("EasyPassword")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalAgeRating")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Password")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithOne("DisplayPreferences")
+ .HasForeignKey("Jellyfin.Data.Entities.DisplayPreferences", "UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("Permission_Permissions_Guid");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("Preference_Preferences_Guid");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences")
+ .IsRequired();
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs
new file mode 100644
index 000000000..fbc0bffa9
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs
@@ -0,0 +1,108 @@
+#pragma warning disable CS1591
+// <auto-generated />
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ public partial class AddCustomDisplayPreferences : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_DisplayPreferences_UserId_Client",
+ schema: "jellyfin",
+ table: "DisplayPreferences");
+
+ migrationBuilder.AlterColumn<int>(
+ name: "MaxActiveSessions",
+ schema: "jellyfin",
+ table: "Users",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: 0,
+ oldClrType: typeof(int),
+ oldType: "INTEGER",
+ oldNullable: true);
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "ItemId",
+ schema: "jellyfin",
+ table: "DisplayPreferences",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.CreateTable(
+ name: "CustomItemDisplayPreferences",
+ schema: "jellyfin",
+ columns: table => new
+ {
+ Id = table.Column<int>(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ UserId = table.Column<Guid>(type: "TEXT", nullable: false),
+ ItemId = table.Column<Guid>(type: "TEXT", nullable: false),
+ Client = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
+ Key = table.Column<string>(type: "TEXT", nullable: false),
+ Value = table.Column<string>(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_CustomItemDisplayPreferences", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DisplayPreferences_UserId_ItemId_Client",
+ schema: "jellyfin",
+ table: "DisplayPreferences",
+ columns: new[] { "UserId", "ItemId", "Client" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_CustomItemDisplayPreferences_UserId",
+ schema: "jellyfin",
+ table: "CustomItemDisplayPreferences",
+ column: "UserId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_CustomItemDisplayPreferences_UserId_ItemId_Client_Key",
+ schema: "jellyfin",
+ table: "CustomItemDisplayPreferences",
+ columns: new[] { "UserId", "ItemId", "Client", "Key" },
+ unique: true);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "CustomItemDisplayPreferences",
+ schema: "jellyfin");
+
+ migrationBuilder.DropIndex(
+ name: "IX_DisplayPreferences_UserId_ItemId_Client",
+ schema: "jellyfin",
+ table: "DisplayPreferences");
+
+ migrationBuilder.DropColumn(
+ name: "ItemId",
+ schema: "jellyfin",
+ table: "DisplayPreferences");
+
+ migrationBuilder.AlterColumn<int>(
+ name: "MaxActiveSessions",
+ schema: "jellyfin",
+ table: "Users",
+ type: "INTEGER",
+ nullable: true,
+ oldClrType: typeof(int),
+ oldType: "INTEGER");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DisplayPreferences_UserId_Client",
+ schema: "jellyfin",
+ table: "DisplayPreferences",
+ columns: new[] { "UserId", "Client" },
+ unique: true);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
index 16d62f482..1614a88ef 100644
--- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
+++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("jellyfin")
- .HasAnnotation("ProductVersion", "3.1.8");
+ .HasAnnotation("ProductVersion", "5.0.0");
modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
{
@@ -52,33 +52,33 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("TEXT");
b.Property<string>("ItemId")
- .HasColumnType("TEXT")
- .HasMaxLength(256);
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
b.Property<int>("LogSeverity")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(512);
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
b.Property<string>("Overview")
- .HasColumnType("TEXT")
- .HasMaxLength(512);
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("ShortOverview")
- .HasColumnType("TEXT")
- .HasMaxLength(512);
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(256);
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
@@ -88,6 +88,41 @@ namespace Jellyfin.Server.Implementations.Migrations
b.ToTable("ActivityLogs");
});
+ modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+ });
+
modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
{
b.Property<int>("Id")
@@ -99,12 +134,12 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("Client")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(32);
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
b.Property<string>("DashboardTheme")
- .HasColumnType("TEXT")
- .HasMaxLength(32);
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
b.Property<bool>("EnableNextVideoInfoOverlay")
.HasColumnType("INTEGER");
@@ -112,6 +147,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<int?>("IndexBy")
.HasColumnType("INTEGER");
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
b.Property<int>("ScrollDirection")
.HasColumnType("INTEGER");
@@ -128,8 +166,8 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("INTEGER");
b.Property<string>("TvHome")
- .HasColumnType("TEXT")
- .HasMaxLength(32);
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
@@ -138,7 +176,7 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasIndex("UserId");
- b.HasIndex("UserId", "Client")
+ b.HasIndex("UserId", "ItemId", "Client")
.IsUnique();
b.ToTable("DisplayPreferences");
@@ -177,8 +215,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("Path")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(512);
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
@@ -199,8 +237,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("Client")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(32);
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
b.Property<int?>("IndexBy")
.HasColumnType("INTEGER");
@@ -216,8 +254,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("SortBy")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(64);
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
@@ -279,8 +317,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("Value")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(65535);
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
b.HasKey("Id");
@@ -296,13 +334,13 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("TEXT");
b.Property<string>("AudioLanguagePreference")
- .HasColumnType("TEXT")
- .HasMaxLength(255);
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
b.Property<string>("AuthenticationProviderId")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(255);
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
b.Property<bool>("DisplayCollectionsView")
.HasColumnType("INTEGER");
@@ -311,8 +349,8 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("INTEGER");
b.Property<string>("EasyPassword")
- .HasColumnType("TEXT")
- .HasMaxLength(65535);
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
b.Property<bool>("EnableAutoLogin")
.HasColumnType("INTEGER");
@@ -354,13 +392,13 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("INTEGER");
b.Property<string>("Password")
- .HasColumnType("TEXT")
- .HasMaxLength(65535);
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
b.Property<string>("PasswordResetProviderId")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(255);
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
b.Property<bool>("PlayDefaultAudioTrack")
.HasColumnType("INTEGER");
@@ -379,8 +417,8 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("INTEGER");
b.Property<string>("SubtitleLanguagePreference")
- .HasColumnType("TEXT")
- .HasMaxLength(255);
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
b.Property<int>("SubtitleMode")
.HasColumnType("INTEGER");
@@ -390,8 +428,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("Username")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(255);
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
b.HasKey("Id");
@@ -454,6 +492,27 @@ namespace Jellyfin.Server.Implementations.Migrations
.WithMany("Preferences")
.HasForeignKey("Preference_Preferences_Guid");
});
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences")
+ .IsRequired();
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
index 76f943385..c8a589cab 100644
--- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
+++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
@@ -26,16 +26,16 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
- public DisplayPreferences GetDisplayPreferences(Guid userId, string client)
+ public DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client)
{
var prefs = _dbContext.DisplayPreferences
.Include(pref => pref.HomeSections)
.FirstOrDefault(pref =>
- pref.UserId == userId && string.Equals(pref.Client, client));
+ pref.UserId == userId && string.Equals(pref.Client, client) && pref.ItemId == itemId);
if (prefs == null)
{
- prefs = new DisplayPreferences(userId, client);
+ prefs = new DisplayPreferences(userId, itemId, client);
_dbContext.DisplayPreferences.Add(prefs);
}
@@ -67,6 +67,34 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
+ public IDictionary<string, string> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client)
+ {
+ return _dbContext.CustomItemDisplayPreferences
+ .AsQueryable()
+ .Where(prefs => prefs.UserId == userId
+ && prefs.ItemId == itemId
+ && string.Equals(prefs.Client, client))
+ .ToDictionary(prefs => prefs.Key, prefs => prefs.Value);
+ }
+
+ /// <inheritdoc />
+ public void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string> customPreferences)
+ {
+ var existingPrefs = _dbContext.CustomItemDisplayPreferences
+ .AsQueryable()
+ .Where(prefs => prefs.UserId == userId
+ && prefs.ItemId == itemId
+ && string.Equals(prefs.Client, client));
+ _dbContext.CustomItemDisplayPreferences.RemoveRange(existingPrefs);
+
+ foreach (var (key, value) in customPreferences)
+ {
+ _dbContext.CustomItemDisplayPreferences
+ .Add(new CustomItemDisplayPreferences(userId, itemId, client, key, value));
+ }
+ }
+
+ /// <inheritdoc />
public void SaveChanges()
{
_dbContext.SaveChanges();
diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs
index 78f596a5c..b76aa5e14 100644
--- a/Jellyfin.Server/CoreAppHost.cs
+++ b/Jellyfin.Server/CoreAppHost.cs
@@ -82,13 +82,11 @@ namespace Jellyfin.Server
ServiceCollection.AddSingleton<IUserManager, UserManager>();
ServiceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>();
- ServiceCollection.AddScoped<IWebSocketListener, SessionWebSocketListener>();
- ServiceCollection.AddScoped<IWebSocketListener, ActivityLogWebSocketListener>();
- ServiceCollection.AddScoped<IWebSocketListener, ScheduledTasksWebSocketListener>();
- ServiceCollection.AddScoped<IWebSocketListener, SessionInfoWebSocketListener>();
-
- // TODO fix circular dependency on IWebSocketManager
- ServiceCollection.AddScoped(serviceProvider => new Lazy<IEnumerable<IWebSocketListener>>(serviceProvider.GetRequiredService<IEnumerable<IWebSocketListener>>));
+ // TODO search the assemblies instead of adding them manually?
+ ServiceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>();
+ ServiceCollection.AddSingleton<IWebSocketListener, ActivityLogWebSocketListener>();
+ ServiceCollection.AddSingleton<IWebSocketListener, ScheduledTasksWebSocketListener>();
+ ServiceCollection.AddSingleton<IWebSocketListener, SessionInfoWebSocketListener>();
base.RegisterServices();
}
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index 618a4e92b..74e7bb4b1 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -15,9 +15,11 @@ using Jellyfin.Api.Auth.IgnoreParentalControlPolicy;
using Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy;
using Jellyfin.Api.Auth.LocalAccessPolicy;
using Jellyfin.Api.Auth.RequiresElevationPolicy;
+using Jellyfin.Api.Auth.SyncPlayAccessPolicy;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Controllers;
using Jellyfin.Api.ModelBinders;
+using Jellyfin.Data.Enums;
using Jellyfin.Server.Configuration;
using Jellyfin.Server.Filters;
using Jellyfin.Server.Formatters;
@@ -58,6 +60,7 @@ namespace Jellyfin.Server.Extensions
serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessHandler>();
serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessOrRequiresElevationHandler>();
serviceCollection.AddSingleton<IAuthorizationHandler, RequiresElevationHandler>();
+ serviceCollection.AddSingleton<IAuthorizationHandler, SyncPlayAccessHandler>();
return serviceCollection.AddAuthorizationCore(options =>
{
options.AddPolicy(
@@ -123,6 +126,20 @@ namespace Jellyfin.Server.Extensions
policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
policy.AddRequirements(new RequiresElevationRequirement());
});
+ options.AddPolicy(
+ Policies.SyncPlayAccess,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.JoinGroups));
+ });
+ options.AddPolicy(
+ Policies.SyncPlayCreateGroupAccess,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.CreateAndJoinGroups));
+ });
});
}
diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs
index aca165408..305660ae6 100644
--- a/Jellyfin.Server/Migrations/MigrationRunner.cs
+++ b/Jellyfin.Server/Migrations/MigrationRunner.cs
@@ -24,7 +24,8 @@ namespace Jellyfin.Server.Migrations
typeof(Routines.MigrateUserDb),
typeof(Routines.ReaddDefaultPluginRepository),
typeof(Routines.MigrateDisplayPreferencesDb),
- typeof(Routines.RemoveDownloadImagesInAdvance)
+ typeof(Routines.RemoveDownloadImagesInAdvance),
+ typeof(Routines.AddPeopleQueryIndex)
};
/// <summary>
diff --git a/Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs b/Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs
new file mode 100644
index 000000000..2521d9952
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs
@@ -0,0 +1,49 @@
+using System;
+using System.IO;
+using MediaBrowser.Controller;
+using Microsoft.Extensions.Logging;
+using SQLitePCL.pretty;
+
+namespace Jellyfin.Server.Migrations.Routines
+{
+ /// <summary>
+ /// Migration to add table indexes to optimize the Persons query.
+ /// </summary>
+ public class AddPeopleQueryIndex : IMigrationRoutine
+ {
+ private const string DbFilename = "library.db";
+ private readonly ILogger<AddPeopleQueryIndex> _logger;
+ private readonly IServerApplicationPaths _serverApplicationPaths;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AddPeopleQueryIndex"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger{AddPeopleQueryIndex}"/> interface.</param>
+ /// <param name="serverApplicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param>
+ public AddPeopleQueryIndex(ILogger<AddPeopleQueryIndex> logger, IServerApplicationPaths serverApplicationPaths)
+ {
+ _logger = logger;
+ _serverApplicationPaths = serverApplicationPaths;
+ }
+
+ /// <inheritdoc />
+ public Guid Id => new Guid("DE009B59-BAAE-428D-A810-F67762DC05B8");
+
+ /// <inheritdoc />
+ public string Name => "AddPeopleQueryIndex";
+
+ /// <inheritdoc />
+ public bool PerformOnNewInstall => true;
+
+ /// <inheritdoc />
+ public void Perform()
+ {
+ var databasePath = Path.Join(_serverApplicationPaths.DataPath, DbFilename);
+ using var connection = SQLite3.Open(databasePath, ConnectionFlags.ReadWrite, null);
+ _logger.LogInformation("Creating index idx_TypedBaseItemsUserDataKeyType");
+ connection.Execute("CREATE INDEX idx_TypedBaseItemsUserDataKeyType ON TypedBaseItems(UserDataKey, Type);");
+ _logger.LogInformation("Creating index idx_PeopleNameListOrder");
+ connection.Execute("CREATE INDEX idx_PeopleNameListOrder ON People(Name, ListOrder);");
+ }
+ }
+} \ No newline at end of file
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
index 8992c281d..af4be5a26 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
@@ -8,6 +8,7 @@ using System.Text.Json.Serialization;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Server.Implementations;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
@@ -94,6 +95,7 @@ namespace Jellyfin.Server.Migrations.Routines
continue;
}
+ var itemId = new Guid(result[1].ToBlob());
var dtoUserId = new Guid(result[1].ToBlob());
var existingUser = _userManager.GetUserById(dtoUserId);
if (existingUser == null)
@@ -105,8 +107,9 @@ namespace Jellyfin.Server.Migrations.Routines
var chromecastVersion = dto.CustomPrefs.TryGetValue("chromecastVersion", out var version)
? chromecastDict[version]
: ChromecastVersion.Stable;
+ dto.CustomPrefs.Remove("chromecastVersion");
- var displayPreferences = new DisplayPreferences(dtoUserId, result[2].ToString())
+ var displayPreferences = new DisplayPreferences(dtoUserId, itemId, result[2].ToString())
{
IndexBy = Enum.TryParse<IndexingKind>(dto.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null,
ShowBackdrop = dto.ShowBackdrop,
@@ -126,15 +129,24 @@ namespace Jellyfin.Server.Migrations.Routines
TvHome = dto.CustomPrefs.TryGetValue("tvhome", out var home) ? home : string.Empty
};
+ dto.CustomPrefs.Remove("skipForwardLength");
+ dto.CustomPrefs.Remove("skipBackLength");
+ dto.CustomPrefs.Remove("enableNextVideoInfoOverlay");
+ dto.CustomPrefs.Remove("dashboardtheme");
+ dto.CustomPrefs.Remove("tvhome");
+
for (int i = 0; i < 7; i++)
{
- dto.CustomPrefs.TryGetValue("homesection" + i, out var homeSection);
+ var key = "homesection" + i;
+ dto.CustomPrefs.TryGetValue(key, out var homeSection);
displayPreferences.HomeSections.Add(new HomeSection
{
Order = i,
Type = Enum.TryParse<HomeSectionType>(homeSection, true, out var type) ? type : defaults[i]
});
+
+ dto.CustomPrefs.Remove(key);
}
var defaultLibraryPrefs = new ItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client)
@@ -149,12 +161,12 @@ namespace Jellyfin.Server.Migrations.Routines
foreach (var key in dto.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.Ordinal)))
{
- if (!Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var itemId))
+ if (!Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var landingItemId))
{
continue;
}
- var libraryDisplayPreferences = new ItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client)
+ var libraryDisplayPreferences = new ItemDisplayPreferences(displayPreferences.UserId, landingItemId, displayPreferences.Client)
{
SortBy = dto.SortBy ?? "SortName",
SortOrder = dto.SortOrder,
@@ -167,9 +179,15 @@ namespace Jellyfin.Server.Migrations.Routines
libraryDisplayPreferences.ViewType = viewType;
}
+ dto.CustomPrefs.Remove(key);
dbContext.ItemDisplayPreferences.Add(libraryDisplayPreferences);
}
+ foreach (var (key, value) in dto.CustomPrefs)
+ {
+ dbContext.Add(new CustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client, key, value));
+ }
+
dbContext.Add(displayPreferences);
}
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 7f1d332ee..aa3ef5350 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -133,8 +133,9 @@ namespace Jellyfin.Server
{
var extensionProvider = new FileExtensionContentTypeProvider();
- // subtitles octopus requires .data files.
+ // subtitles octopus requires .data, .mem files.
extensionProvider.Mappings.Add(".data", MediaTypeNames.Application.Octet);
+ extensionProvider.Mappings.Add(".mem", MediaTypeNames.Application.Octet);
mainApp.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(_serverConfigurationManager.ApplicationPaths.WebPath),
diff --git a/MediaBrowser.Controller/IDisplayPreferencesManager.cs b/MediaBrowser.Controller/IDisplayPreferencesManager.cs
index 6658269bd..041eeea62 100644
--- a/MediaBrowser.Controller/IDisplayPreferencesManager.cs
+++ b/MediaBrowser.Controller/IDisplayPreferencesManager.cs
@@ -16,9 +16,10 @@ namespace MediaBrowser.Controller
/// This will create the display preferences if it does not exist, but it will not save automatically.
/// </remarks>
/// <param name="userId">The user's id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="client">The client string.</param>
/// <returns>The associated display preferences.</returns>
- DisplayPreferences GetDisplayPreferences(Guid userId, string client);
+ DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client);
/// <summary>
/// Gets the default item display preferences for the user and client.
@@ -41,6 +42,24 @@ namespace MediaBrowser.Controller
IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client);
/// <summary>
+ /// Gets all of the custom item display preferences for the user and client.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="client">The client string.</param>
+ /// <returns>The dictionary of custom item display preferences.</returns>
+ IDictionary<string, string> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client);
+
+ /// <summary>
+ /// Sets the custom item display preference for the user and client.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="client">The client id.</param>
+ /// <param name="customPreferences">A dictionary of custom item display preferences.</param>
+ void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string> customPreferences);
+
+ /// <summary>
/// Saves changes made to the database.
/// </summary>
void SaveChanges();
diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
index 28227603b..163a9c8f8 100644
--- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
+++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
@@ -92,6 +92,9 @@ namespace MediaBrowser.Controller.Net
return Task.CompletedTask;
}
+ /// <inheritdoc />
+ public Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection) => Task.CompletedTask;
+
/// <summary>
/// Starts sending messages over a web socket.
/// </summary>
diff --git a/MediaBrowser.Controller/Net/IWebSocketListener.cs b/MediaBrowser.Controller/Net/IWebSocketListener.cs
index 7250a57b0..f1a75d518 100644
--- a/MediaBrowser.Controller/Net/IWebSocketListener.cs
+++ b/MediaBrowser.Controller/Net/IWebSocketListener.cs
@@ -3,7 +3,7 @@ using System.Threading.Tasks;
namespace MediaBrowser.Controller.Net
{
/// <summary>
- ///This is an interface for listening to messages coming through a web socket connection.
+ /// Interface for listening to messages coming through a web socket connection.
/// </summary>
public interface IWebSocketListener
{
@@ -13,5 +13,12 @@ namespace MediaBrowser.Controller.Net
/// <param name="message">The message.</param>
/// <returns>Task.</returns>
Task ProcessMessageAsync(WebSocketMessageInfo message);
+
+ /// <summary>
+ /// Processes a new web socket connection.
+ /// </summary>
+ /// <param name="connection">An instance of the <see cref="IWebSocketConnection"/> interface.</param>
+ /// <returns>Task.</returns>
+ Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection);
}
}
diff --git a/MediaBrowser.Controller/Net/IWebSocketManager.cs b/MediaBrowser.Controller/Net/IWebSocketManager.cs
index ce74173e7..bb0ae83be 100644
--- a/MediaBrowser.Controller/Net/IWebSocketManager.cs
+++ b/MediaBrowser.Controller/Net/IWebSocketManager.cs
@@ -1,7 +1,4 @@
-using System;
-using System.Collections.Generic;
using System.Threading.Tasks;
-using Jellyfin.Data.Events;
using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Controller.Net
@@ -12,11 +9,6 @@ namespace MediaBrowser.Controller.Net
public interface IWebSocketManager
{
/// <summary>
- /// Occurs when [web socket connected].
- /// </summary>
- event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
-
- /// <summary>
/// The HTTP request handler.
/// </summary>
/// <param name="context">The current HTTP context.</param>
diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs
index 04c3004ee..9ad8557ce 100644
--- a/MediaBrowser.Controller/Session/ISessionManager.cs
+++ b/MediaBrowser.Controller/Session/ISessionManager.cs
@@ -143,22 +143,22 @@ namespace MediaBrowser.Controller.Session
Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken);
/// <summary>
- /// Sends the SyncPlayCommand.
+ /// Sends a SyncPlayCommand to a session.
/// </summary>
- /// <param name="sessionId">The session id.</param>
+ /// <param name="session">The session.</param>
/// <param name="command">The command.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
- Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken);
+ Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken);
/// <summary>
- /// Sends the SyncPlayGroupUpdate.
+ /// Sends a SyncPlayGroupUpdate to a session.
/// </summary>
- /// <param name="sessionId">The session id.</param>
+ /// <param name="session">The session.</param>
/// <param name="command">The group update.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
- Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken);
+ Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken);
/// <summary>
/// Sends the browse command.
diff --git a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs b/MediaBrowser.Controller/SyncPlay/GroupInfo.cs
deleted file mode 100644
index a1cada25c..000000000
--- a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs
+++ /dev/null
@@ -1,160 +0,0 @@
-using System;
-using System.Collections.Generic;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Session;
-
-namespace MediaBrowser.Controller.SyncPlay
-{
- /// <summary>
- /// Class GroupInfo.
- /// </summary>
- /// <remarks>
- /// Class is not thread-safe, external locking is required when accessing methods.
- /// </remarks>
- public class GroupInfo
- {
- /// <summary>
- /// The default ping value used for sessions.
- /// </summary>
- public const long DefaultPing = 500;
-
- /// <summary>
- /// Gets the group identifier.
- /// </summary>
- /// <value>The group identifier.</value>
- public Guid GroupId { get; } = Guid.NewGuid();
-
- /// <summary>
- /// Gets or sets the playing item.
- /// </summary>
- /// <value>The playing item.</value>
- public BaseItem PlayingItem { get; set; }
-
- /// <summary>
- /// Gets or sets a value indicating whether playback is paused.
- /// </summary>
- /// <value>Playback is paused.</value>
- public bool IsPaused { get; set; }
-
- /// <summary>
- /// Gets or sets a value indicating whether there are 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>
- /// Gets the participants.
- /// </summary>
- /// <value>The participants, or members of the group.</value>
- public Dictionary<string, GroupMember> Participants { get; } =
- new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase);
-
- /// <summary>
- /// Checks if a session is in this group.
- /// </summary>
- /// <param name="sessionId">The session id to check.</param>
- /// <returns><c>true</c> if the session is in this group; <c>false</c> otherwise.</returns>
- public bool ContainsSession(string sessionId)
- {
- return Participants.ContainsKey(sessionId);
- }
-
- /// <summary>
- /// Adds the session to the group.
- /// </summary>
- /// <param name="session">The session.</param>
- public void AddSession(SessionInfo session)
- {
- Participants.TryAdd(
- session.Id,
- new GroupMember
- {
- Session = session,
- Ping = DefaultPing,
- IsBuffering = false
- });
- }
-
- /// <summary>
- /// Removes the session from the group.
- /// </summary>
- /// <param name="session">The session.</param>
- public void RemoveSession(SessionInfo session)
- {
- Participants.Remove(session.Id);
- }
-
- /// <summary>
- /// Updates the ping of a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="ping">The ping.</param>
- public void UpdatePing(SessionInfo session, long ping)
- {
- if (Participants.TryGetValue(session.Id, out GroupMember value))
- {
- value.Ping = ping;
- }
- }
-
- /// <summary>
- /// Gets the highest ping in the group.
- /// </summary>
- /// <returns>The highest ping in the group.</returns>
- public long GetHighestPing()
- {
- long max = long.MinValue;
- foreach (var session in Participants.Values)
- {
- max = Math.Max(max, session.Ping);
- }
-
- return max;
- }
-
- /// <summary>
- /// Sets the session's buffering state.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="isBuffering">The state.</param>
- public void SetBuffering(SessionInfo session, bool isBuffering)
- {
- if (Participants.TryGetValue(session.Id, out GroupMember value))
- {
- value.IsBuffering = isBuffering;
- }
- }
-
- /// <summary>
- /// Gets the group buffering state.
- /// </summary>
- /// <returns><c>true</c> if there is a session buffering in the group; <c>false</c> otherwise.</returns>
- public bool IsBuffering()
- {
- foreach (var session in Participants.Values)
- {
- if (session.IsBuffering)
- {
- return true;
- }
- }
-
- return false;
- }
-
- /// <summary>
- /// Checks if the group is empty.
- /// </summary>
- /// <returns><c>true</c> if the group is empty; <c>false</c> otherwise.</returns>
- public bool IsEmpty()
- {
- return Participants.Count == 0;
- }
- }
-}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupMember.cs b/MediaBrowser.Controller/SyncPlay/GroupMember.cs
index cde6f8e8c..5fb982e85 100644
--- a/MediaBrowser.Controller/SyncPlay/GroupMember.cs
+++ b/MediaBrowser.Controller/SyncPlay/GroupMember.cs
@@ -8,21 +8,36 @@ namespace MediaBrowser.Controller.SyncPlay
public class GroupMember
{
/// <summary>
- /// Gets or sets a value indicating whether this member is buffering.
+ /// Initializes a new instance of the <see cref="GroupMember"/> class.
/// </summary>
- /// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value>
- public bool IsBuffering { get; set; }
+ /// <param name="session">The session.</param>
+ public GroupMember(SessionInfo session)
+ {
+ Session = session;
+ }
/// <summary>
- /// Gets or sets the session.
+ /// Gets the session.
/// </summary>
/// <value>The session.</value>
- public SessionInfo Session { get; set; }
+ public SessionInfo Session { get; }
/// <summary>
- /// Gets or sets the ping.
+ /// Gets or sets the ping, in milliseconds.
/// </summary>
/// <value>The ping.</value>
public long Ping { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether this member is buffering.
+ /// </summary>
+ /// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value>
+ public bool IsBuffering { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether this member is following group playback.
+ /// </summary>
+ /// <value><c>true</c> to ignore member on group wait; <c>false</c> if they're following group playback.</value>
+ public bool IgnoreGroupWait { get; set; }
}
}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs
new file mode 100644
index 000000000..e3de22db3
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs
@@ -0,0 +1,222 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+ /// <summary>
+ /// Class AbstractGroupState.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public abstract class AbstractGroupState : IGroupState
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<AbstractGroupState> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AbstractGroupState"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ protected AbstractGroupState(ILoggerFactory loggerFactory)
+ {
+ LoggerFactory = loggerFactory;
+ _logger = loggerFactory.CreateLogger<AbstractGroupState>();
+ }
+
+ /// <inheritdoc />
+ public abstract GroupStateType Type { get; }
+
+ /// <summary>
+ /// Gets the logger factory.
+ /// </summary>
+ protected ILoggerFactory LoggerFactory { get; }
+
+ /// <inheritdoc />
+ public abstract void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <inheritdoc />
+ public abstract void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(IGroupPlaybackRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(RemoveFromPlaylistGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ var playingItemRemoved = context.RemoveFromPlayQueue(request.PlaylistItemIds);
+
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RemoveItems);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+ if (playingItemRemoved && !context.PlayQueue.IsItemPlaying())
+ {
+ _logger.LogDebug("Play queue in group {GroupId} is now empty.", context.GroupId.ToString());
+
+ IGroupState idleState = new IdleGroupState(LoggerFactory);
+ context.SetState(idleState);
+ var stopRequest = new StopGroupRequest();
+ idleState.HandleRequest(stopRequest, context, Type, session, cancellationToken);
+ }
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(MovePlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ var result = context.MoveItemInPlayQueue(request.PlaylistItemId, request.NewIndex);
+
+ if (!result)
+ {
+ _logger.LogError("Unable to move item in group {GroupId}.", context.GroupId.ToString());
+ return;
+ }
+
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.MoveItem);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(QueueGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ var result = context.AddToPlayQueue(request.ItemIds, request.Mode);
+
+ if (!result)
+ {
+ _logger.LogError("Unable to add items to play queue in group {GroupId}.", context.GroupId.ToString());
+ return;
+ }
+
+ var reason = request.Mode switch
+ {
+ GroupQueueMode.QueueNext => PlayQueueUpdateReason.QueueNext,
+ _ => PlayQueueUpdateReason.Queue
+ };
+ var playQueueUpdate = context.GetPlayQueueUpdate(reason);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(SetRepeatModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ context.SetRepeatMode(request.Mode);
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RepeatMode);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(SetShuffleModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ context.SetShuffleMode(request.Mode);
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.ShuffleMode);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(PingGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Collected pings are used to account for network latency when unpausing playback.
+ context.UpdatePing(session, request.Ping);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ context.SetIgnoreGroupWait(session, request.IgnoreWait);
+ }
+
+ /// <summary>
+ /// Sends a group state update to all group.
+ /// </summary>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="reason">The reason of the state change.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ protected void SendGroupStateUpdate(IGroupStateContext context, IGroupPlaybackRequest reason, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Notify relevant state change event.
+ var stateUpdate = new GroupStateUpdate(Type, reason.Action);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.StateUpdate, stateUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ private void UnhandledRequest(IGroupPlaybackRequest request)
+ {
+ _logger.LogWarning("Unhandled request of type {RequestType} in {StateType} state.", request.Action, Type);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs
new file mode 100644
index 000000000..12ce6c8f8
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs
@@ -0,0 +1,126 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+ /// <summary>
+ /// Class IdleGroupState.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public class IdleGroupState : AbstractGroupState
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<IdleGroupState> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IdleGroupState"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ public IdleGroupState(ILoggerFactory loggerFactory)
+ : base(loggerFactory)
+ {
+ _logger = LoggerFactory.CreateLogger<IdleGroupState>();
+ }
+
+ /// <inheritdoc />
+ public override GroupStateType Type { get; } = GroupStateType.Idle;
+
+ /// <inheritdoc />
+ public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ SendStopCommand(context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Do nothing.
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ SendStopCommand(context, prevState, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ SendStopCommand(context, prevState, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ SendStopCommand(context, prevState, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ SendStopCommand(context, prevState, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ SendStopCommand(context, prevState, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ private void SendStopCommand(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ var command = context.NewSyncPlayCommand(SendCommandType.Stop);
+ if (!prevState.Equals(Type))
+ {
+ context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+ }
+ else
+ {
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs
new file mode 100644
index 000000000..fba8ba9e2
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+ /// <summary>
+ /// Class PausedGroupState.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public class PausedGroupState : AbstractGroupState
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<PausedGroupState> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PausedGroupState"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ public PausedGroupState(ILoggerFactory loggerFactory)
+ : base(loggerFactory)
+ {
+ _logger = LoggerFactory.CreateLogger<PausedGroupState>();
+ }
+
+ /// <inheritdoc />
+ public override GroupStateType Type { get; } = GroupStateType.Paused;
+
+ /// <inheritdoc />
+ public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Wait for session to be ready.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.SessionJoined(context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Do nothing.
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var playingState = new PlayingGroupState(LoggerFactory);
+ context.SetState(playingState);
+ playingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ if (!prevState.Equals(Type))
+ {
+ // Pause group and compute the media playback position.
+ var currentTime = DateTime.UtcNow;
+ var elapsedTime = currentTime - context.LastActivity;
+ context.LastActivity = currentTime;
+ // 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.
+ // Seek only if playback actually started.
+ context.PositionTicks += Math.Max(elapsedTime.Ticks, 0);
+
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+ else
+ {
+ // Client got lost, sending current state.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var idleState = new IdleGroupState(LoggerFactory);
+ context.SetState(idleState);
+ idleState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ if (prevState.Equals(Type))
+ {
+ // Client got lost, sending current state.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ else if (prevState.Equals(GroupStateType.Waiting))
+ {
+ // Sending current state to all clients.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs
new file mode 100644
index 000000000..9797b247c
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs
@@ -0,0 +1,168 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+ /// <summary>
+ /// Class PlayingGroupState.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public class PlayingGroupState : AbstractGroupState
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<PlayingGroupState> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlayingGroupState"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ public PlayingGroupState(ILoggerFactory loggerFactory)
+ : base(loggerFactory)
+ {
+ _logger = LoggerFactory.CreateLogger<PlayingGroupState>();
+ }
+
+ /// <inheritdoc />
+ public override GroupStateType Type { get; } = GroupStateType.Playing;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether requests for buffering should be ignored.
+ /// </summary>
+ public bool IgnoreBuffering { get; set; }
+
+ /// <inheritdoc />
+ public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Wait for session to be ready.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.SessionJoined(context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Do nothing.
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ if (!prevState.Equals(Type))
+ {
+ // Pick a suitable time that accounts for latency.
+ var delayMillis = Math.Max(context.GetHighestPing() * 2, context.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.
+ context.LastActivity = DateTime.UtcNow.AddMilliseconds(delayMillis);
+
+ var command = context.NewSyncPlayCommand(SendCommandType.Unpause);
+ context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+ else
+ {
+ // Client got lost, sending current state.
+ var command = context.NewSyncPlayCommand(SendCommandType.Unpause);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var pausedState = new PausedGroupState(LoggerFactory);
+ context.SetState(pausedState);
+ pausedState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var idleState = new IdleGroupState(LoggerFactory);
+ context.SetState(idleState);
+ idleState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ if (IgnoreBuffering)
+ {
+ return;
+ }
+
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ if (prevState.Equals(Type))
+ {
+ // Group was not waiting, make sure client has latest state.
+ var command = context.NewSyncPlayCommand(SendCommandType.Unpause);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ else if (prevState.Equals(GroupStateType.Waiting))
+ {
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs
new file mode 100644
index 000000000..507573653
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs
@@ -0,0 +1,680 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+ /// <summary>
+ /// Class WaitingGroupState.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public class WaitingGroupState : AbstractGroupState
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<WaitingGroupState> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="WaitingGroupState"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ public WaitingGroupState(ILoggerFactory loggerFactory)
+ : base(loggerFactory)
+ {
+ _logger = LoggerFactory.CreateLogger<WaitingGroupState>();
+ }
+
+ /// <inheritdoc />
+ public override GroupStateType Type { get; } = GroupStateType.Waiting;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether playback should resume when group is ready.
+ /// </summary>
+ public bool ResumePlaying { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the initial state has been set.
+ /// </summary>
+ private bool InitialStateSet { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets the group state before the first ever event.
+ /// </summary>
+ private GroupStateType InitialState { get; set; }
+
+ /// <inheritdoc />
+ public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ if (prevState.Equals(GroupStateType.Playing))
+ {
+ ResumePlaying = true;
+ // Pause group and compute the media playback position.
+ var currentTime = DateTime.UtcNow;
+ var elapsedTime = currentTime - context.LastActivity;
+ context.LastActivity = currentTime;
+ // 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.
+ // Seek only if playback actually started.
+ context.PositionTicks += Math.Max(elapsedTime.Ticks, 0);
+ }
+
+ // Prepare new session.
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken);
+
+ context.SetBuffering(session, true);
+
+ // Send pause command to all non-buffering sessions.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ context.SetBuffering(session, false);
+
+ if (!context.IsBuffering())
+ {
+ if (ResumePlaying)
+ {
+ _logger.LogDebug("Session {SessionId} left group {GroupId}, notifying others to resume.", session.Id, context.GroupId.ToString());
+
+ // Client, that was buffering, left the group.
+ var playingState = new PlayingGroupState(LoggerFactory);
+ context.SetState(playingState);
+ var unpauseRequest = new UnpauseGroupRequest();
+ playingState.HandleRequest(unpauseRequest, context, Type, session, cancellationToken);
+ }
+ else
+ {
+ _logger.LogDebug("Session {SessionId} left group {GroupId}, returning to previous state.", session.Id, context.GroupId.ToString());
+
+ // Group is ready, returning to previous state.
+ var pausedState = new PausedGroupState(LoggerFactory);
+ context.SetState(pausedState);
+ }
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ ResumePlaying = true;
+
+ var setQueueStatus = context.SetPlayQueue(request.PlayingQueue, request.PlayingItemPosition, request.StartPositionTicks);
+ if (!setQueueStatus)
+ {
+ _logger.LogError("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString());
+
+ // Ignore request and return to previous state.
+ IGroupState newState = prevState switch {
+ GroupStateType.Playing => new PlayingGroupState(LoggerFactory),
+ GroupStateType.Paused => new PausedGroupState(LoggerFactory),
+ _ => new IdleGroupState(LoggerFactory)
+ };
+
+ context.SetState(newState);
+ return;
+ }
+
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+ // Reset status of sessions and await for all Ready events.
+ context.SetAllBuffering(true);
+
+ _logger.LogDebug("Session {SessionId} set a new play queue in group {GroupId}.", session.Id, context.GroupId.ToString());
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ ResumePlaying = true;
+
+ var result = context.SetPlayingItem(request.PlaylistItemId);
+ if (result)
+ {
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+ // Reset status of sessions and await for all Ready events.
+ context.SetAllBuffering(true);
+ }
+ else
+ {
+ // Return to old state.
+ IGroupState newState = prevState switch
+ {
+ GroupStateType.Playing => new PlayingGroupState(LoggerFactory),
+ GroupStateType.Paused => new PausedGroupState(LoggerFactory),
+ _ => new IdleGroupState(LoggerFactory)
+ };
+
+ context.SetState(newState);
+
+ _logger.LogDebug("Unable to change current playing item in group {GroupId}.", context.GroupId.ToString());
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ if (prevState.Equals(GroupStateType.Idle))
+ {
+ ResumePlaying = true;
+ context.RestartCurrentItem();
+
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+ // Reset status of sessions and await for all Ready events.
+ context.SetAllBuffering(true);
+
+ _logger.LogDebug("Group {GroupId} is waiting for all ready events.", context.GroupId.ToString());
+ }
+ else
+ {
+ if (ResumePlaying)
+ {
+ _logger.LogDebug("Forcing the playback to start in group {GroupId}. Group-wait is disabled until next state change.", context.GroupId.ToString());
+
+ // An Unpause request is forcing the playback to start, ignoring sessions that are not ready.
+ context.SetAllBuffering(false);
+
+ // Change state.
+ var playingState = new PlayingGroupState(LoggerFactory)
+ {
+ IgnoreBuffering = true
+ };
+ context.SetState(playingState);
+ playingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+ else
+ {
+ // Group would have gone to paused state, now will go to playing state when ready.
+ ResumePlaying = true;
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ // Wait for sessions to be ready, then switch to paused state.
+ ResumePlaying = false;
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ // Change state.
+ var idleState = new IdleGroupState(LoggerFactory);
+ context.SetState(idleState);
+ idleState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ if (prevState.Equals(GroupStateType.Playing))
+ {
+ ResumePlaying = true;
+ }
+ else if (prevState.Equals(GroupStateType.Paused))
+ {
+ ResumePlaying = false;
+ }
+
+ // Sanitize PositionTicks.
+ var ticks = context.SanitizePositionTicks(request.PositionTicks);
+
+ // Seek.
+ context.PositionTicks = ticks;
+ context.LastActivity = DateTime.UtcNow;
+
+ var command = context.NewSyncPlayCommand(SendCommandType.Seek);
+ context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ // Reset status of sessions and await for all Ready events.
+ context.SetAllBuffering(true);
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ // Make sure the client is playing the correct item.
+ if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId()))
+ {
+ _logger.LogDebug("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.ToString());
+
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem);
+ var updateSession = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
+ context.SetBuffering(session, true);
+
+ return;
+ }
+
+ if (prevState.Equals(GroupStateType.Playing))
+ {
+ // Resume playback when all ready.
+ ResumePlaying = true;
+
+ context.SetBuffering(session, true);
+
+ // Pause group and compute the media playback position.
+ var currentTime = DateTime.UtcNow;
+ var elapsedTime = currentTime - context.LastActivity;
+ context.LastActivity = currentTime;
+ // 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.
+ // Seek only if playback actually started.
+ context.PositionTicks += Math.Max(elapsedTime.Ticks, 0);
+
+ // Send pause command to all non-buffering sessions.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken);
+ }
+ else if (prevState.Equals(GroupStateType.Paused))
+ {
+ // Don't resume playback when all ready.
+ ResumePlaying = false;
+
+ context.SetBuffering(session, true);
+
+ // Send pause command to buffering session.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ else if (prevState.Equals(GroupStateType.Waiting))
+ {
+ // Another session is now buffering.
+ context.SetBuffering(session, true);
+
+ if (!ResumePlaying)
+ {
+ // Force update for this session that should be paused.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ }
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ // Make sure the client is playing the correct item.
+ if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId()))
+ {
+ _logger.LogDebug("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.ToString());
+
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken);
+ context.SetBuffering(session, true);
+
+ return;
+ }
+
+ // Compute elapsed time between the client reported time and now.
+ // Elapsed time is used to estimate the client position when playback is unpaused.
+ // Ideally, the request is received and handled without major delays.
+ // However, to avoid waiting indefinitely when a client is not reporting a correct time,
+ // the elapsed time is ignored after a certain threshold.
+ var currentTime = DateTime.UtcNow;
+ var elapsedTime = currentTime.Subtract(request.When);
+ var timeSyncThresholdTicks = TimeSpan.FromMilliseconds(context.TimeSyncOffset).Ticks;
+ if (Math.Abs(elapsedTime.Ticks) > timeSyncThresholdTicks)
+ {
+ _logger.LogWarning("Session {SessionId} is not time syncing properly. Ignoring elapsed time.", session.Id);
+
+ elapsedTime = TimeSpan.Zero;
+ }
+
+ // Ignore elapsed time if client is paused.
+ if (!request.IsPlaying)
+ {
+ elapsedTime = TimeSpan.Zero;
+ }
+
+ var requestTicks = context.SanitizePositionTicks(request.PositionTicks);
+ var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime;
+ var delayTicks = context.PositionTicks - clientPosition.Ticks;
+ var maxPlaybackOffsetTicks = TimeSpan.FromMilliseconds(context.MaxPlaybackOffset).Ticks;
+
+ _logger.LogDebug("Session {SessionId} is at {PositionTicks} (delay of {Delay} seconds) in group {GroupId}.", session.Id, clientPosition, TimeSpan.FromTicks(delayTicks).TotalSeconds, context.GroupId.ToString());
+
+ if (ResumePlaying)
+ {
+ // Handle case where session reported as ready but in reality
+ // it has no clue of the real position nor the playback state.
+ if (!request.IsPlaying && Math.Abs(delayTicks) > maxPlaybackOffsetTicks)
+ {
+ // Session not ready at all.
+ context.SetBuffering(session, true);
+
+ // Correcting session's position.
+ var command = context.NewSyncPlayCommand(SendCommandType.Seek);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+
+ _logger.LogWarning("Session {SessionId} got lost in time, correcting.", session.Id);
+ return;
+ }
+
+ // Session is ready.
+ context.SetBuffering(session, false);
+
+ if (context.IsBuffering())
+ {
+ // Others are still buffering, tell this client to pause when ready.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ command.When = currentTime.AddTicks(delayTicks);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+
+ _logger.LogInformation("Session {SessionId} will pause when ready in {Delay} seconds. Group {GroupId} is waiting for all ready events.", session.Id, TimeSpan.FromTicks(delayTicks).TotalSeconds, context.GroupId.ToString());
+ }
+ else
+ {
+ // If all ready, then start playback.
+ // Let other clients resume as soon as the buffering client catches up.
+ if (delayTicks > context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond)
+ {
+ // Client that was buffering is recovering, notifying others to resume.
+ context.LastActivity = currentTime.AddTicks(delayTicks);
+ var command = context.NewSyncPlayCommand(SendCommandType.Unpause);
+ var filter = SyncPlayBroadcastType.AllExceptCurrentSession;
+ if (!request.IsPlaying)
+ {
+ filter = SyncPlayBroadcastType.AllGroup;
+ }
+
+ context.SendCommand(session, filter, command, cancellationToken);
+
+ _logger.LogInformation("Session {SessionId} is recovering, group {GroupId} will resume in {Delay} seconds.", session.Id, context.GroupId.ToString(), TimeSpan.FromTicks(delayTicks).TotalSeconds);
+ }
+ else
+ {
+ // Client, that was buffering, resumed playback but did not update others in time.
+ delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond;
+ delayTicks = Math.Max(delayTicks, context.DefaultPing);
+
+ context.LastActivity = currentTime.AddTicks(delayTicks);
+
+ var command = context.NewSyncPlayCommand(SendCommandType.Unpause);
+ context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ _logger.LogWarning("Session {SessionId} resumed playback, group {GroupId} has {Delay} seconds to recover.", session.Id, context.GroupId.ToString(), TimeSpan.FromTicks(delayTicks).TotalSeconds);
+ }
+
+ // Change state.
+ var playingState = new PlayingGroupState(LoggerFactory);
+ context.SetState(playingState);
+ playingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+ }
+ else
+ {
+ // Check that session is really ready, tolerate player imperfections under a certain threshold.
+ if (Math.Abs(context.PositionTicks - requestTicks) > maxPlaybackOffsetTicks)
+ {
+ // Session still not ready.
+ context.SetBuffering(session, true);
+ // Session is seeking to wrong position, correcting.
+ var command = context.NewSyncPlayCommand(SendCommandType.Seek);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+
+ _logger.LogWarning("Session {SessionId} is seeking to wrong position, correcting.", session.Id);
+ return;
+ }
+ else
+ {
+ // Session is ready.
+ context.SetBuffering(session, false);
+ }
+
+ if (!context.IsBuffering())
+ {
+ _logger.LogDebug("Session {SessionId} is ready, group {GroupId} is ready.", session.Id, context.GroupId.ToString());
+
+ // Group is ready, returning to previous state.
+ var pausedState = new PausedGroupState(LoggerFactory);
+ context.SetState(pausedState);
+
+ if (InitialState.Equals(GroupStateType.Playing))
+ {
+ // Group went from playing to waiting state and a pause request occured while waiting.
+ var pauseRequest = new PauseGroupRequest();
+ pausedState.HandleRequest(pauseRequest, context, Type, session, cancellationToken);
+ }
+ else if (InitialState.Equals(GroupStateType.Paused))
+ {
+ pausedState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+ }
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ ResumePlaying = true;
+
+ // Make sure the client knows the playing item, to avoid duplicate requests.
+ if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId()))
+ {
+ _logger.LogDebug("Session {SessionId} provided the wrong playlist item for group {GroupId}.", session.Id, context.GroupId.ToString());
+ return;
+ }
+
+ var newItem = context.NextItemInQueue();
+ if (newItem)
+ {
+ // Send playing-queue update.
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NextItem);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+ // Reset status of sessions and await for all Ready events.
+ context.SetAllBuffering(true);
+ }
+ else
+ {
+ // Return to old state.
+ IGroupState newState = prevState switch
+ {
+ GroupStateType.Playing => new PlayingGroupState(LoggerFactory),
+ GroupStateType.Paused => new PausedGroupState(LoggerFactory),
+ _ => new IdleGroupState(LoggerFactory)
+ };
+
+ context.SetState(newState);
+
+ _logger.LogDebug("No next item available in group {GroupId}.", context.GroupId.ToString());
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ ResumePlaying = true;
+
+ // Make sure the client knows the playing item, to avoid duplicate requests.
+ if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId()))
+ {
+ _logger.LogDebug("Session {SessionId} provided the wrong playlist item for group {GroupId}.", session.Id, context.GroupId.ToString());
+ return;
+ }
+
+ var newItem = context.PreviousItemInQueue();
+ if (newItem)
+ {
+ // Send playing-queue update.
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.PreviousItem);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+ // Reset status of sessions and await for all Ready events.
+ context.SetAllBuffering(true);
+ }
+ else
+ {
+ // Return to old state.
+ IGroupState newState = prevState switch
+ {
+ GroupStateType.Playing => new PlayingGroupState(LoggerFactory),
+ GroupStateType.Paused => new PausedGroupState(LoggerFactory),
+ _ => new IdleGroupState(LoggerFactory)
+ };
+
+ context.SetState(newState);
+
+ _logger.LogDebug("No previous item available in group {GroupId}.", context.GroupId.ToString());
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ context.SetIgnoreGroupWait(session, request.IgnoreWait);
+
+ if (!context.IsBuffering())
+ {
+ _logger.LogDebug("Ignoring session {SessionId}, group {GroupId} is ready.", session.Id, context.GroupId.ToString());
+
+ if (ResumePlaying)
+ {
+ // Client, that was buffering, stopped following playback.
+ var playingState = new PlayingGroupState(LoggerFactory);
+ context.SetState(playingState);
+ var unpauseRequest = new UnpauseGroupRequest();
+ playingState.HandleRequest(unpauseRequest, context, Type, session, cancellationToken);
+ }
+ else
+ {
+ // Group is ready, returning to previous state.
+ var pausedState = new PausedGroupState(LoggerFactory);
+ context.SetState(pausedState);
+ }
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs b/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs
new file mode 100644
index 000000000..201f29952
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs
@@ -0,0 +1,27 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+ /// <summary>
+ /// Interface IGroupPlaybackRequest.
+ /// </summary>
+ public interface IGroupPlaybackRequest : ISyncPlayRequest
+ {
+ /// <summary>
+ /// Gets the playback request type.
+ /// </summary>
+ /// <returns>The playback request type.</returns>
+ PlaybackRequestType Action { get; }
+
+ /// <summary>
+ /// Applies the request to a group.
+ /// </summary>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="state">The current state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken);
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/IGroupState.cs b/MediaBrowser.Controller/SyncPlay/IGroupState.cs
new file mode 100644
index 000000000..95ee09985
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/IGroupState.cs
@@ -0,0 +1,217 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+ /// <summary>
+ /// Interface IGroupState.
+ /// </summary>
+ public interface IGroupState
+ {
+ /// <summary>
+ /// Gets the group state type.
+ /// </summary>
+ /// <value>The group state type.</value>
+ GroupStateType Type { get; }
+
+ /// <summary>
+ /// Handles a session that joined the group.
+ /// </summary>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a session that is leaving the group.
+ /// </summary>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Generic handler. Context's state can change.
+ /// </summary>
+ /// <param name="request">The generic request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(IGroupPlaybackRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a play request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The play request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a set-playlist-item request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The set-playlist-item request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a remove-items request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The remove-items request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(RemoveFromPlaylistGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a move-playlist-item request from a session. Context's state should not change.
+ /// </summary>
+ /// <param name="request">The move-playlist-item request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(MovePlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a queue request from a session. Context's state should not change.
+ /// </summary>
+ /// <param name="request">The queue request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(QueueGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles an unpause request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The unpause request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a pause request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The pause request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a stop request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The stop request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a seek request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The seek request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a buffer request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The buffer request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a ready request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The ready request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a next-item request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The next-item request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a previous-item request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The previous-item request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a set-repeat-mode request from a session. Context's state should not change.
+ /// </summary>
+ /// <param name="request">The repeat-mode request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(SetRepeatModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a set-shuffle-mode request from a session. Context's state should not change.
+ /// </summary>
+ /// <param name="request">The shuffle-mode request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(SetShuffleModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Updates the ping of a session. Context's state should not change.
+ /// </summary>
+ /// <param name="request">The ping request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(PingGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a ignore-wait request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The ignore-wait request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs
new file mode 100644
index 000000000..aa263638a
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs
@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.Queue;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+ /// <summary>
+ /// Interface IGroupStateContext.
+ /// </summary>
+ public interface IGroupStateContext
+ {
+ /// <summary>
+ /// Gets the default ping value used for sessions, in milliseconds.
+ /// </summary>
+ /// <value>The default ping value used for sessions, in milliseconds.</value>
+ long DefaultPing { get; }
+
+ /// <summary>
+ /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
+ /// </summary>
+ /// <value>The maximum offset error accepted, in milliseconds.</value>
+ long TimeSyncOffset { get; }
+
+ /// <summary>
+ /// Gets the maximum offset error accepted for position reported by clients, in milliseconds.
+ /// </summary>
+ /// <value>The maximum offset error accepted, in milliseconds.</value>
+ long MaxPlaybackOffset { get; }
+
+ /// <summary>
+ /// Gets the group identifier.
+ /// </summary>
+ /// <value>The group identifier.</value>
+ Guid GroupId { get; }
+
+ /// <summary>
+ /// Gets or sets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ long PositionTicks { get; set; }
+
+ /// <summary>
+ /// Gets or sets the last activity.
+ /// </summary>
+ /// <value>The last activity.</value>
+ DateTime LastActivity { get; set; }
+
+ /// <summary>
+ /// Gets the play queue.
+ /// </summary>
+ /// <value>The play queue.</value>
+ PlayQueueManager PlayQueue { get; }
+
+ /// <summary>
+ /// Sets a new state.
+ /// </summary>
+ /// <param name="state">The new state.</param>
+ void SetState(IGroupState state);
+
+ /// <summary>
+ /// Sends a GroupUpdate message to the interested sessions.
+ /// </summary>
+ /// <typeparam name="T">The type of the data of the message.</typeparam>
+ /// <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>
+ /// <returns>The task.</returns>
+ Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken);
+
+ /// <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>
+ /// <returns>The task.</returns>
+ Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Builds a new playback command with some default values.
+ /// </summary>
+ /// <param name="type">The command type.</param>
+ /// <returns>The command.</returns>
+ SendCommand NewSyncPlayCommand(SendCommandType type);
+
+ /// <summary>
+ /// Builds a new group update message.
+ /// </summary>
+ /// <typeparam name="T">The type of the data of the message.</typeparam>
+ /// <param name="type">The update type.</param>
+ /// <param name="data">The data to send.</param>
+ /// <returns>The group update.</returns>
+ GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data);
+
+ /// <summary>
+ /// Sanitizes the PositionTicks, considers the current playing item when available.
+ /// </summary>
+ /// <param name="positionTicks">The PositionTicks.</param>
+ /// <returns>The sanitized position ticks.</returns>
+ long SanitizePositionTicks(long? positionTicks);
+
+ /// <summary>
+ /// Updates the ping of a session, in milliseconds.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="ping">The ping, in milliseconds.</param>
+ void UpdatePing(SessionInfo session, long ping);
+
+ /// <summary>
+ /// Gets the highest ping in the group, in milliseconds.
+ /// </summary>
+ /// <returns>The highest ping in the group.</returns>
+ long GetHighestPing();
+
+ /// <summary>
+ /// Sets the session's buffering state.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="isBuffering">The state.</param>
+ void SetBuffering(SessionInfo session, bool isBuffering);
+
+ /// <summary>
+ /// Sets the buffering state of all the sessions.
+ /// </summary>
+ /// <param name="isBuffering">The state.</param>
+ void SetAllBuffering(bool isBuffering);
+
+ /// <summary>
+ /// Gets the group buffering state.
+ /// </summary>
+ /// <returns><c>true</c> if there is a session buffering in the group; <c>false</c> otherwise.</returns>
+ bool IsBuffering();
+
+ /// <summary>
+ /// Sets the session's group wait state.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="ignoreGroupWait">The state.</param>
+ void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait);
+
+ /// <summary>
+ /// Sets a new play queue.
+ /// </summary>
+ /// <param name="playQueue">The new play queue.</param>
+ /// <param name="playingItemPosition">The playing item position in the play queue.</param>
+ /// <param name="startPositionTicks">The start position ticks.</param>
+ /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns>
+ bool SetPlayQueue(IReadOnlyList<Guid> playQueue, int playingItemPosition, long startPositionTicks);
+
+ /// <summary>
+ /// Sets the playing item.
+ /// </summary>
+ /// <param name="playlistItemId">The new playing item identifier.</param>
+ /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns>
+ bool SetPlayingItem(Guid playlistItemId);
+
+ /// <summary>
+ /// Removes items from the play queue.
+ /// </summary>
+ /// <param name="playlistItemIds">The items to remove.</param>
+ /// <returns><c>true</c> if playing item got removed; <c>false</c> otherwise.</returns>
+ bool RemoveFromPlayQueue(IReadOnlyList<Guid> playlistItemIds);
+
+ /// <summary>
+ /// Moves an item in the play queue.
+ /// </summary>
+ /// <param name="playlistItemId">The playlist identifier of the item to move.</param>
+ /// <param name="newIndex">The new position.</param>
+ /// <returns><c>true</c> if item has been moved; <c>false</c> if something went wrong.</returns>
+ bool MoveItemInPlayQueue(Guid playlistItemId, int newIndex);
+
+ /// <summary>
+ /// Updates the play queue.
+ /// </summary>
+ /// <param name="newItems">The new items to add to the play queue.</param>
+ /// <param name="mode">The mode with which the items will be added.</param>
+ /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns>
+ bool AddToPlayQueue(IReadOnlyList<Guid> newItems, GroupQueueMode mode);
+
+ /// <summary>
+ /// Restarts current item in play queue.
+ /// </summary>
+ void RestartCurrentItem();
+
+ /// <summary>
+ /// Picks next item in play queue.
+ /// </summary>
+ /// <returns><c>true</c> if the item changed; <c>false</c> otherwise.</returns>
+ bool NextItemInQueue();
+
+ /// <summary>
+ /// Picks previous item in play queue.
+ /// </summary>
+ /// <returns><c>true</c> if the item changed; <c>false</c> otherwise.</returns>
+ bool PreviousItemInQueue();
+
+ /// <summary>
+ /// Sets the repeat mode.
+ /// </summary>
+ /// <param name="mode">The new mode.</param>
+ void SetRepeatMode(GroupRepeatMode mode);
+
+ /// <summary>
+ /// Sets the shuffle mode.
+ /// </summary>
+ /// <param name="mode">The new mode.</param>
+ void SetShuffleMode(GroupShuffleMode mode);
+
+ /// <summary>
+ /// Creates a play queue update.
+ /// </summary>
+ /// <param name="reason">The reason for the update.</param>
+ /// <returns>The play queue update.</returns>
+ PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason);
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs
deleted file mode 100644
index 60d17fe36..000000000
--- a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs
+++ /dev/null
@@ -1,67 +0,0 @@
-using System;
-using System.Threading;
-using MediaBrowser.Controller.Session;
-using MediaBrowser.Model.SyncPlay;
-
-namespace MediaBrowser.Controller.SyncPlay
-{
- /// <summary>
- /// Interface ISyncPlayController.
- /// </summary>
- public interface ISyncPlayController
- {
- /// <summary>
- /// Gets the group id.
- /// </summary>
- /// <value>The group id.</value>
- Guid GetGroupId();
-
- /// <summary>
- /// Gets the playing item id.
- /// </summary>
- /// <value>The playing item id.</value>
- Guid GetPlayingItemId();
-
- /// <summary>
- /// Checks if the group is empty.
- /// </summary>
- /// <value>If the group is empty.</value>
- bool IsGroupEmpty();
-
- /// <summary>
- /// Initializes the group with the session's info.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- void CreateGroup(SessionInfo session, CancellationToken cancellationToken);
-
- /// <summary>
- /// Adds the session to the group.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The request.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken);
-
- /// <summary>
- /// Removes the session from the group.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- void SessionLeave(SessionInfo session, CancellationToken cancellationToken);
-
- /// <summary>
- /// Handles the requested action by the session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The requested action.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken);
-
- /// <summary>
- /// Gets the info about the group for the clients.
- /// </summary>
- /// <value>The group info for the clients.</value>
- GroupInfoView GetInfo();
- }
-}
diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
index 006fb687b..d0244563a 100644
--- a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
+++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.Requests;
using MediaBrowser.Model.SyncPlay;
namespace MediaBrowser.Controller.SyncPlay
@@ -15,32 +16,33 @@ namespace MediaBrowser.Controller.SyncPlay
/// Creates a new group.
/// </summary>
/// <param name="session">The session that's creating the group.</param>
+ /// <param name="request">The request.</param>
/// <param name="cancellationToken">The cancellation token.</param>
- void NewGroup(SessionInfo session, CancellationToken cancellationToken);
+ void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken);
/// <summary>
/// Adds the session to a group.
/// </summary>
/// <param name="session">The session.</param>
- /// <param name="groupId">The group id.</param>
/// <param name="request">The request.</param>
/// <param name="cancellationToken">The cancellation token.</param>
- void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken);
+ void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken);
/// <summary>
/// Removes the session from a group.
/// </summary>
/// <param name="session">The session.</param>
+ /// <param name="request">The request.</param>
/// <param name="cancellationToken">The cancellation token.</param>
- void LeaveGroup(SessionInfo session, CancellationToken cancellationToken);
+ void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken);
/// <summary>
/// Gets list of available groups for a session.
/// </summary>
/// <param name="session">The session.</param>
- /// <param name="filterItemId">The item id to filter by.</param>
- /// <value>The list of available groups.</value>
- List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId);
+ /// <param name="request">The request.</param>
+ /// <returns>The list of available groups.</returns>
+ List<GroupInfoDto> ListGroups(SessionInfo session, ListGroupsRequest request);
/// <summary>
/// Handle a request by a session in a group.
@@ -48,22 +50,6 @@ namespace MediaBrowser.Controller.SyncPlay
/// <param name="session">The session.</param>
/// <param name="request">The request.</param>
/// <param name="cancellationToken">The cancellation token.</param>
- void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken);
-
- /// <summary>
- /// Maps a session to a group.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="group">The group.</param>
- /// <exception cref="InvalidOperationException"></exception>
- void AddSessionToGroup(SessionInfo session, ISyncPlayController group);
-
- /// <summary>
- /// Unmaps a session from a group.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="group">The group.</param>
- /// <exception cref="InvalidOperationException"></exception>
- void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group);
+ void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken);
}
}
diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs
new file mode 100644
index 000000000..bf1981773
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs
@@ -0,0 +1,16 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+ /// <summary>
+ /// Interface ISyncPlayRequest.
+ /// </summary>
+ public interface ISyncPlayRequest
+ {
+ /// <summary>
+ /// Gets the request type.
+ /// </summary>
+ /// <returns>The request type.</returns>
+ RequestType Type { get; }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs
new file mode 100644
index 000000000..4090f65b9
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs
@@ -0,0 +1,29 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class AbstractPlaybackRequest.
+ /// </summary>
+ public abstract class AbstractPlaybackRequest : IGroupPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AbstractPlaybackRequest"/> class.
+ /// </summary>
+ protected AbstractPlaybackRequest()
+ {
+ // Do nothing.
+ }
+
+ /// <inheritdoc />
+ public RequestType Type { get; } = RequestType.Playback;
+
+ /// <inheritdoc />
+ public abstract PlaybackRequestType Action { get; }
+
+ /// <inheritdoc />
+ public abstract void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken);
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs
new file mode 100644
index 000000000..11cc99fcd
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class BufferGroupRequest.
+ /// </summary>
+ public class BufferGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BufferGroupRequest"/> class.
+ /// </summary>
+ /// <param name="when">When the request has been made, as reported by the client.</param>
+ /// <param name="positionTicks">The position ticks.</param>
+ /// <param name="isPlaying">Whether the client playback is unpaused.</param>
+ /// <param name="playlistItemId">The playlist item identifier of the playing item.</param>
+ public BufferGroupRequest(DateTime when, long positionTicks, bool isPlaying, Guid playlistItemId)
+ {
+ When = when;
+ PositionTicks = positionTicks;
+ IsPlaying = isPlaying;
+ PlaylistItemId = playlistItemId;
+ }
+
+ /// <summary>
+ /// Gets when the request has been made by the client.
+ /// </summary>
+ /// <value>The date of the request.</value>
+ public DateTime When { get; }
+
+ /// <summary>
+ /// Gets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; }
+
+ /// <summary>
+ /// Gets a value indicating whether the client playback is unpaused.
+ /// </summary>
+ /// <value>The client playback status.</value>
+ public bool IsPlaying { get; }
+
+ /// <summary>
+ /// Gets the playlist item identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist item identifier.</value>
+ public Guid PlaylistItemId { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Buffer;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs
new file mode 100644
index 000000000..64ef791ed
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class IgnoreWaitGroupRequest.
+ /// </summary>
+ public class IgnoreWaitGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IgnoreWaitGroupRequest"/> class.
+ /// </summary>
+ /// <param name="ignoreWait">Whether the client should be ignored.</param>
+ public IgnoreWaitGroupRequest(bool ignoreWait)
+ {
+ IgnoreWait = ignoreWait;
+ }
+
+ /// <summary>
+ /// Gets a value indicating whether the client should be ignored.
+ /// </summary>
+ /// <value>The client group-wait status.</value>
+ public bool IgnoreWait { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.IgnoreWait;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs
new file mode 100644
index 000000000..9cd8da566
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class MovePlaylistItemGroupRequest.
+ /// </summary>
+ public class MovePlaylistItemGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MovePlaylistItemGroupRequest"/> class.
+ /// </summary>
+ /// <param name="playlistItemId">The playlist identifier of the item.</param>
+ /// <param name="newIndex">The new position.</param>
+ public MovePlaylistItemGroupRequest(Guid playlistItemId, int newIndex)
+ {
+ PlaylistItemId = playlistItemId;
+ NewIndex = newIndex;
+ }
+
+ /// <summary>
+ /// Gets the playlist identifier of the item.
+ /// </summary>
+ /// <value>The playlist identifier of the item.</value>
+ public Guid PlaylistItemId { get; }
+
+ /// <summary>
+ /// Gets the new position.
+ /// </summary>
+ /// <value>The new position.</value>
+ public int NewIndex { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.MovePlaylistItem;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs
new file mode 100644
index 000000000..e0ae0deb7
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class NextItemGroupRequest.
+ /// </summary>
+ public class NextItemGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NextItemGroupRequest"/> class.
+ /// </summary>
+ /// <param name="playlistItemId">The playing item identifier.</param>
+ public NextItemGroupRequest(Guid playlistItemId)
+ {
+ PlaylistItemId = playlistItemId;
+ }
+
+ /// <summary>
+ /// Gets the playing item identifier.
+ /// </summary>
+ /// <value>The playing item identifier.</value>
+ public Guid PlaylistItemId { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.NextItem;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs
new file mode 100644
index 000000000..2869b35f7
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs
@@ -0,0 +1,21 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class PauseGroupRequest.
+ /// </summary>
+ public class PauseGroupRequest : AbstractPlaybackRequest
+ {
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Pause;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs
new file mode 100644
index 000000000..8ef3b2030
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class PingGroupRequest.
+ /// </summary>
+ public class PingGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PingGroupRequest"/> class.
+ /// </summary>
+ /// <param name="ping">The ping time.</param>
+ public PingGroupRequest(long ping)
+ {
+ Ping = ping;
+ }
+
+ /// <summary>
+ /// Gets the ping time.
+ /// </summary>
+ /// <value>The ping time.</value>
+ public long Ping { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Ping;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs
new file mode 100644
index 000000000..16f9b4087
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class PlayGroupRequest.
+ /// </summary>
+ public class PlayGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlayGroupRequest"/> class.
+ /// </summary>
+ /// <param name="playingQueue">The playing queue.</param>
+ /// <param name="playingItemPosition">The playing item position.</param>
+ /// <param name="startPositionTicks">The start position ticks.</param>
+ public PlayGroupRequest(IReadOnlyList<Guid> playingQueue, int playingItemPosition, long startPositionTicks)
+ {
+ PlayingQueue = playingQueue ?? Array.Empty<Guid>();
+ PlayingItemPosition = playingItemPosition;
+ StartPositionTicks = startPositionTicks;
+ }
+
+ /// <summary>
+ /// Gets the playing queue.
+ /// </summary>
+ /// <value>The playing queue.</value>
+ public IReadOnlyList<Guid> PlayingQueue { get; }
+
+ /// <summary>
+ /// Gets the position of the playing item in the queue.
+ /// </summary>
+ /// <value>The playing item position.</value>
+ public int PlayingItemPosition { get; }
+
+ /// <summary>
+ /// Gets the start position ticks.
+ /// </summary>
+ /// <value>The start position ticks.</value>
+ public long StartPositionTicks { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Play;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs
new file mode 100644
index 000000000..166ee0800
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class PreviousItemGroupRequest.
+ /// </summary>
+ public class PreviousItemGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PreviousItemGroupRequest"/> class.
+ /// </summary>
+ /// <param name="playlistItemId">The playing item identifier.</param>
+ public PreviousItemGroupRequest(Guid playlistItemId)
+ {
+ PlaylistItemId = playlistItemId;
+ }
+
+ /// <summary>
+ /// Gets the playing item identifier.
+ /// </summary>
+ /// <value>The playing item identifier.</value>
+ public Guid PlaylistItemId { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.PreviousItem;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs
new file mode 100644
index 000000000..d4af63b6d
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class QueueGroupRequest.
+ /// </summary>
+ public class QueueGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="QueueGroupRequest"/> class.
+ /// </summary>
+ /// <param name="items">The items to add to the queue.</param>
+ /// <param name="mode">The enqueue mode.</param>
+ public QueueGroupRequest(IReadOnlyList<Guid> items, GroupQueueMode mode)
+ {
+ ItemIds = items ?? Array.Empty<Guid>();
+ Mode = mode;
+ }
+
+ /// <summary>
+ /// Gets the items to enqueue.
+ /// </summary>
+ /// <value>The items to enqueue.</value>
+ public IReadOnlyList<Guid> ItemIds { get; }
+
+ /// <summary>
+ /// Gets the mode in which to add the new items.
+ /// </summary>
+ /// <value>The enqueue mode.</value>
+ public GroupQueueMode Mode { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Queue;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs
new file mode 100644
index 000000000..74f01cbea
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class ReadyGroupRequest.
+ /// </summary>
+ public class ReadyGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ReadyGroupRequest"/> class.
+ /// </summary>
+ /// <param name="when">When the request has been made, as reported by the client.</param>
+ /// <param name="positionTicks">The position ticks.</param>
+ /// <param name="isPlaying">Whether the client playback is unpaused.</param>
+ /// <param name="playlistItemId">The playlist item identifier of the playing item.</param>
+ public ReadyGroupRequest(DateTime when, long positionTicks, bool isPlaying, Guid playlistItemId)
+ {
+ When = when;
+ PositionTicks = positionTicks;
+ IsPlaying = isPlaying;
+ PlaylistItemId = playlistItemId;
+ }
+
+ /// <summary>
+ /// Gets when the request has been made by the client.
+ /// </summary>
+ /// <value>The date of the request.</value>
+ public DateTime When { get; }
+
+ /// <summary>
+ /// Gets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; }
+
+ /// <summary>
+ /// Gets a value indicating whether the client playback is unpaused.
+ /// </summary>
+ /// <value>The client playback status.</value>
+ public bool IsPlaying { get; }
+
+ /// <summary>
+ /// Gets the playlist item identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist item identifier.</value>
+ public Guid PlaylistItemId { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Ready;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs
new file mode 100644
index 000000000..47c06c222
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class RemoveFromPlaylistGroupRequest.
+ /// </summary>
+ public class RemoveFromPlaylistGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RemoveFromPlaylistGroupRequest"/> class.
+ /// </summary>
+ /// <param name="items">The playlist ids of the items to remove.</param>
+ public RemoveFromPlaylistGroupRequest(IReadOnlyList<Guid> items)
+ {
+ PlaylistItemIds = items ?? Array.Empty<Guid>();
+ }
+
+ /// <summary>
+ /// Gets the playlist identifiers ot the items.
+ /// </summary>
+ /// <value>The playlist identifiers ot the items.</value>
+ public IReadOnlyList<Guid> PlaylistItemIds { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.RemoveFromPlaylist;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs
new file mode 100644
index 000000000..ecaa689ae
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class SeekGroupRequest.
+ /// </summary>
+ public class SeekGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SeekGroupRequest"/> class.
+ /// </summary>
+ /// <param name="positionTicks">The position ticks.</param>
+ public SeekGroupRequest(long positionTicks)
+ {
+ PositionTicks = positionTicks;
+ }
+
+ /// <summary>
+ /// Gets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Seek;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs
new file mode 100644
index 000000000..c3451703e
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class SetPlaylistItemGroupRequest.
+ /// </summary>
+ public class SetPlaylistItemGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SetPlaylistItemGroupRequest"/> class.
+ /// </summary>
+ /// <param name="playlistItemId">The playlist identifier of the item.</param>
+ public SetPlaylistItemGroupRequest(Guid playlistItemId)
+ {
+ PlaylistItemId = playlistItemId;
+ }
+
+ /// <summary>
+ /// Gets the playlist identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist identifier of the playing item.</value>
+ public Guid PlaylistItemId { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetPlaylistItem;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs
new file mode 100644
index 000000000..51011672e
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class SetRepeatModeGroupRequest.
+ /// </summary>
+ public class SetRepeatModeGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SetRepeatModeGroupRequest"/> class.
+ /// </summary>
+ /// <param name="mode">The repeat mode.</param>
+ public SetRepeatModeGroupRequest(GroupRepeatMode mode)
+ {
+ Mode = mode;
+ }
+
+ /// <summary>
+ /// Gets the repeat mode.
+ /// </summary>
+ /// <value>The repeat mode.</value>
+ public GroupRepeatMode Mode { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetRepeatMode;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs
new file mode 100644
index 000000000..d7b2504b4
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class SetShuffleModeGroupRequest.
+ /// </summary>
+ public class SetShuffleModeGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SetShuffleModeGroupRequest"/> class.
+ /// </summary>
+ /// <param name="mode">The shuffle mode.</param>
+ public SetShuffleModeGroupRequest(GroupShuffleMode mode)
+ {
+ Mode = mode;
+ }
+
+ /// <summary>
+ /// Gets the shuffle mode.
+ /// </summary>
+ /// <value>The shuffle mode.</value>
+ public GroupShuffleMode Mode { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetShuffleMode;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs
new file mode 100644
index 000000000..ad739213c
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs
@@ -0,0 +1,21 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class StopGroupRequest.
+ /// </summary>
+ public class StopGroupRequest : AbstractPlaybackRequest
+ {
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Stop;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs
new file mode 100644
index 000000000..aaf3d65a8
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs
@@ -0,0 +1,21 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class UnpauseGroupRequest.
+ /// </summary>
+ public class UnpauseGroupRequest : AbstractPlaybackRequest
+ {
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Unpause;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
new file mode 100644
index 000000000..fdec29417
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
@@ -0,0 +1,577 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Queue
+{
+ /// <summary>
+ /// Class PlayQueueManager.
+ /// </summary>
+ public class PlayQueueManager
+ {
+ /// <summary>
+ /// Placeholder index for when no item is playing.
+ /// </summary>
+ /// <value>The no-playing item index.</value>
+ private const int NoPlayingItemIndex = -1;
+
+ /// <summary>
+ /// Random number generator used to shuffle lists.
+ /// </summary>
+ /// <value>The random number generator.</value>
+ private readonly Random _randomNumberGenerator = new Random();
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlayQueueManager" /> class.
+ /// </summary>
+ public PlayQueueManager()
+ {
+ Reset();
+ }
+
+ /// <summary>
+ /// Gets the playing item index.
+ /// </summary>
+ /// <value>The playing item index.</value>
+ public int PlayingItemIndex { get; private set; }
+
+ /// <summary>
+ /// Gets the last time the queue has been changed.
+ /// </summary>
+ /// <value>The last time the queue has been changed.</value>
+ public DateTime LastChange { get; private set; }
+
+ /// <summary>
+ /// Gets the shuffle mode.
+ /// </summary>
+ /// <value>The shuffle mode.</value>
+ public GroupShuffleMode ShuffleMode { get; private set; } = GroupShuffleMode.Sorted;
+
+ /// <summary>
+ /// Gets the repeat mode.
+ /// </summary>
+ /// <value>The repeat mode.</value>
+ public GroupRepeatMode RepeatMode { get; private set; } = GroupRepeatMode.RepeatNone;
+
+ /// <summary>
+ /// Gets or sets the sorted playlist.
+ /// </summary>
+ /// <value>The sorted playlist, or play queue of the group.</value>
+ private List<QueueItem> SortedPlaylist { get; set; } = new List<QueueItem>();
+
+ /// <summary>
+ /// Gets or sets the shuffled playlist.
+ /// </summary>
+ /// <value>The shuffled playlist, or play queue of the group.</value>
+ private List<QueueItem> ShuffledPlaylist { get; set; } = new List<QueueItem>();
+
+ /// <summary>
+ /// Checks if an item is playing.
+ /// </summary>
+ /// <returns><c>true</c> if an item is playing; <c>false</c> otherwise.</returns>
+ public bool IsItemPlaying()
+ {
+ return PlayingItemIndex != NoPlayingItemIndex;
+ }
+
+ /// <summary>
+ /// Gets the current playlist considering the shuffle mode.
+ /// </summary>
+ /// <returns>The playlist.</returns>
+ public IReadOnlyList<QueueItem> GetPlaylist()
+ {
+ return GetPlaylistInternal();
+ }
+
+ /// <summary>
+ /// Sets a new playlist. Playing item is reset.
+ /// </summary>
+ /// <param name="items">The new items of the playlist.</param>
+ public void SetPlaylist(IReadOnlyList<Guid> items)
+ {
+ SortedPlaylist.Clear();
+ ShuffledPlaylist.Clear();
+
+ SortedPlaylist = CreateQueueItemsFromArray(items);
+ if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+ {
+ ShuffledPlaylist = new List<QueueItem>(SortedPlaylist);
+ Shuffle(ShuffledPlaylist);
+ }
+
+ PlayingItemIndex = NoPlayingItemIndex;
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Appends new items to the playlist. The specified order is mantained.
+ /// </summary>
+ /// <param name="items">The items to add to the playlist.</param>
+ public void Queue(IReadOnlyList<Guid> items)
+ {
+ var newItems = CreateQueueItemsFromArray(items);
+
+ SortedPlaylist.AddRange(newItems);
+ if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+ {
+ ShuffledPlaylist.AddRange(newItems);
+ }
+
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Shuffles the playlist. Shuffle mode is changed. The playlist gets re-shuffled if already shuffled.
+ /// </summary>
+ public void ShufflePlaylist()
+ {
+ if (PlayingItemIndex == NoPlayingItemIndex)
+ {
+ ShuffledPlaylist = new List<QueueItem>(SortedPlaylist);
+ Shuffle(ShuffledPlaylist);
+ }
+ else if (ShuffleMode.Equals(GroupShuffleMode.Sorted))
+ {
+ // First time shuffle.
+ var playingItem = SortedPlaylist[PlayingItemIndex];
+ ShuffledPlaylist = new List<QueueItem>(SortedPlaylist);
+ ShuffledPlaylist.RemoveAt(PlayingItemIndex);
+ Shuffle(ShuffledPlaylist);
+ ShuffledPlaylist.Insert(0, playingItem);
+ PlayingItemIndex = 0;
+ }
+ else
+ {
+ // Re-shuffle playlist.
+ var playingItem = ShuffledPlaylist[PlayingItemIndex];
+ ShuffledPlaylist.RemoveAt(PlayingItemIndex);
+ Shuffle(ShuffledPlaylist);
+ ShuffledPlaylist.Insert(0, playingItem);
+ PlayingItemIndex = 0;
+ }
+
+ ShuffleMode = GroupShuffleMode.Shuffle;
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Resets the playlist to sorted mode. Shuffle mode is changed.
+ /// </summary>
+ public void RestoreSortedPlaylist()
+ {
+ if (PlayingItemIndex != NoPlayingItemIndex)
+ {
+ var playingItem = ShuffledPlaylist[PlayingItemIndex];
+ PlayingItemIndex = SortedPlaylist.IndexOf(playingItem);
+ }
+
+ ShuffledPlaylist.Clear();
+
+ ShuffleMode = GroupShuffleMode.Sorted;
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Clears the playlist. Shuffle mode is preserved.
+ /// </summary>
+ /// <param name="clearPlayingItem">Whether to remove the playing item as well.</param>
+ public void ClearPlaylist(bool clearPlayingItem)
+ {
+ var playingItem = GetPlayingItem();
+ SortedPlaylist.Clear();
+ ShuffledPlaylist.Clear();
+ LastChange = DateTime.UtcNow;
+
+ if (!clearPlayingItem && playingItem != null)
+ {
+ SortedPlaylist.Add(playingItem);
+ if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+ {
+ ShuffledPlaylist.Add(playingItem);
+ }
+
+ PlayingItemIndex = 0;
+ }
+ else
+ {
+ PlayingItemIndex = NoPlayingItemIndex;
+ }
+ }
+
+ /// <summary>
+ /// Adds new items to the playlist right after the playing item. The specified order is mantained.
+ /// </summary>
+ /// <param name="items">The items to add to the playlist.</param>
+ public void QueueNext(IReadOnlyList<Guid> items)
+ {
+ var newItems = CreateQueueItemsFromArray(items);
+
+ if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+ {
+ var playingItem = GetPlayingItem();
+ var sortedPlayingItemIndex = SortedPlaylist.IndexOf(playingItem);
+ // Append items to sorted and shuffled playlist as they are.
+ SortedPlaylist.InsertRange(sortedPlayingItemIndex + 1, newItems);
+ ShuffledPlaylist.InsertRange(PlayingItemIndex + 1, newItems);
+ }
+ else
+ {
+ SortedPlaylist.InsertRange(PlayingItemIndex + 1, newItems);
+ }
+
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Gets playlist identifier of the playing item, if any.
+ /// </summary>
+ /// <returns>The playlist identifier of the playing item.</returns>
+ public Guid GetPlayingItemPlaylistId()
+ {
+ var playingItem = GetPlayingItem();
+ return playingItem?.PlaylistItemId ?? Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets the playing item identifier, if any.
+ /// </summary>
+ /// <returns>The playing item identifier.</returns>
+ public Guid GetPlayingItemId()
+ {
+ var playingItem = GetPlayingItem();
+ return playingItem?.ItemId ?? Guid.Empty;
+ }
+
+ /// <summary>
+ /// Sets the playing item using its identifier. If not in the playlist, the playing item is reset.
+ /// </summary>
+ /// <param name="itemId">The new playing item identifier.</param>
+ public void SetPlayingItemById(Guid itemId)
+ {
+ var playlist = GetPlaylistInternal();
+ PlayingItemIndex = playlist.FindIndex(item => item.ItemId.Equals(itemId));
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Sets the playing item using its playlist identifier. If not in the playlist, the playing item is reset.
+ /// </summary>
+ /// <param name="playlistItemId">The new playing item identifier.</param>
+ /// <returns><c>true</c> if playing item has been set; <c>false</c> if item is not in the playlist.</returns>
+ public bool SetPlayingItemByPlaylistId(Guid playlistItemId)
+ {
+ var playlist = GetPlaylistInternal();
+ PlayingItemIndex = playlist.FindIndex(item => item.PlaylistItemId.Equals(playlistItemId));
+ LastChange = DateTime.UtcNow;
+
+ return PlayingItemIndex != NoPlayingItemIndex;
+ }
+
+ /// <summary>
+ /// Sets the playing item using its position. If not in range, the playing item is reset.
+ /// </summary>
+ /// <param name="playlistIndex">The new playing item index.</param>
+ public void SetPlayingItemByIndex(int playlistIndex)
+ {
+ var playlist = GetPlaylistInternal();
+ if (playlistIndex < 0 || playlistIndex > playlist.Count)
+ {
+ PlayingItemIndex = NoPlayingItemIndex;
+ }
+ else
+ {
+ PlayingItemIndex = playlistIndex;
+ }
+
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Removes items from the playlist. If not removed, the playing item is preserved.
+ /// </summary>
+ /// <param name="playlistItemIds">The items to remove.</param>
+ /// <returns><c>true</c> if playing item got removed; <c>false</c> otherwise.</returns>
+ public bool RemoveFromPlaylist(IReadOnlyList<Guid> playlistItemIds)
+ {
+ var playingItem = GetPlayingItem();
+
+ SortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
+ ShuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
+
+ LastChange = DateTime.UtcNow;
+
+ if (playingItem != null)
+ {
+ if (playlistItemIds.Contains(playingItem.PlaylistItemId))
+ {
+ // Playing item has been removed, picking previous item.
+ PlayingItemIndex--;
+ if (PlayingItemIndex < 0)
+ {
+ // Was first element, picking next if available.
+ // Default to no playing item otherwise.
+ PlayingItemIndex = SortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex;
+ }
+
+ return true;
+ }
+ else
+ {
+ // Restoring playing item.
+ SetPlayingItemByPlaylistId(playingItem.PlaylistItemId);
+ return false;
+ }
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ /// <summary>
+ /// Moves an item in the playlist to another position.
+ /// </summary>
+ /// <param name="playlistItemId">The item to move.</param>
+ /// <param name="newIndex">The new position.</param>
+ /// <returns><c>true</c> if the item has been moved; <c>false</c> otherwise.</returns>
+ public bool MovePlaylistItem(Guid playlistItemId, int newIndex)
+ {
+ var playlist = GetPlaylistInternal();
+ var playingItem = GetPlayingItem();
+
+ var oldIndex = playlist.FindIndex(item => item.PlaylistItemId.Equals(playlistItemId));
+ if (oldIndex < 0)
+ {
+ return false;
+ }
+
+ var queueItem = playlist[oldIndex];
+ playlist.RemoveAt(oldIndex);
+ newIndex = Math.Clamp(newIndex, 0, playlist.Count);
+ playlist.Insert(newIndex, queueItem);
+
+ LastChange = DateTime.UtcNow;
+ PlayingItemIndex = playlist.IndexOf(playingItem);
+ return true;
+ }
+
+ /// <summary>
+ /// Resets the playlist to its initial state.
+ /// </summary>
+ public void Reset()
+ {
+ SortedPlaylist.Clear();
+ ShuffledPlaylist.Clear();
+ PlayingItemIndex = NoPlayingItemIndex;
+ ShuffleMode = GroupShuffleMode.Sorted;
+ RepeatMode = GroupRepeatMode.RepeatNone;
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Sets the repeat mode.
+ /// </summary>
+ /// <param name="mode">The new mode.</param>
+ public void SetRepeatMode(GroupRepeatMode mode)
+ {
+ RepeatMode = mode;
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Sets the shuffle mode.
+ /// </summary>
+ /// <param name="mode">The new mode.</param>
+ public void SetShuffleMode(GroupShuffleMode mode)
+ {
+ if (mode.Equals(GroupShuffleMode.Shuffle))
+ {
+ ShufflePlaylist();
+ }
+ else
+ {
+ RestoreSortedPlaylist();
+ }
+ }
+
+ /// <summary>
+ /// Toggles the shuffle mode between sorted and shuffled.
+ /// </summary>
+ public void ToggleShuffleMode()
+ {
+ if (ShuffleMode.Equals(GroupShuffleMode.Sorted))
+ {
+ ShufflePlaylist();
+ }
+ else
+ {
+ RestoreSortedPlaylist();
+ }
+ }
+
+ /// <summary>
+ /// Gets the next item in the playlist considering repeat mode and shuffle mode.
+ /// </summary>
+ /// <returns>The next item in the playlist.</returns>
+ public QueueItem GetNextItemPlaylistId()
+ {
+ int newIndex;
+ var playlist = GetPlaylistInternal();
+
+ switch (RepeatMode)
+ {
+ case GroupRepeatMode.RepeatOne:
+ newIndex = PlayingItemIndex;
+ break;
+ case GroupRepeatMode.RepeatAll:
+ newIndex = PlayingItemIndex + 1;
+ if (newIndex >= playlist.Count)
+ {
+ newIndex = 0;
+ }
+
+ break;
+ default:
+ newIndex = PlayingItemIndex + 1;
+ break;
+ }
+
+ if (newIndex < 0 || newIndex >= playlist.Count)
+ {
+ return null;
+ }
+
+ return playlist[newIndex];
+ }
+
+ /// <summary>
+ /// Sets the next item in the queue as playing item.
+ /// </summary>
+ /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
+ public bool Next()
+ {
+ if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
+ {
+ LastChange = DateTime.UtcNow;
+ return true;
+ }
+
+ PlayingItemIndex++;
+ if (PlayingItemIndex >= SortedPlaylist.Count)
+ {
+ if (RepeatMode.Equals(GroupRepeatMode.RepeatAll))
+ {
+ PlayingItemIndex = 0;
+ }
+ else
+ {
+ PlayingItemIndex = SortedPlaylist.Count - 1;
+ return false;
+ }
+ }
+
+ LastChange = DateTime.UtcNow;
+ return true;
+ }
+
+ /// <summary>
+ /// Sets the previous item in the queue as playing item.
+ /// </summary>
+ /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
+ public bool Previous()
+ {
+ if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
+ {
+ LastChange = DateTime.UtcNow;
+ return true;
+ }
+
+ PlayingItemIndex--;
+ if (PlayingItemIndex < 0)
+ {
+ if (RepeatMode.Equals(GroupRepeatMode.RepeatAll))
+ {
+ PlayingItemIndex = SortedPlaylist.Count - 1;
+ }
+ else
+ {
+ PlayingItemIndex = 0;
+ return false;
+ }
+ }
+
+ LastChange = DateTime.UtcNow;
+ return true;
+ }
+
+ /// <summary>
+ /// Shuffles a given list.
+ /// </summary>
+ /// <param name="list">The list to shuffle.</param>
+ private void Shuffle<T>(IList<T> list)
+ {
+ int n = list.Count;
+ while (n > 1)
+ {
+ n--;
+ int k = _randomNumberGenerator.Next(n + 1);
+ T value = list[k];
+ list[k] = list[n];
+ list[n] = value;
+ }
+ }
+
+ /// <summary>
+ /// Creates a list from the array of items. Each item is given an unique playlist identifier.
+ /// </summary>
+ /// <returns>The list of queue items.</returns>
+ private List<QueueItem> CreateQueueItemsFromArray(IReadOnlyList<Guid> items)
+ {
+ var list = new List<QueueItem>();
+ foreach (var item in items)
+ {
+ var queueItem = new QueueItem(item);
+ list.Add(queueItem);
+ }
+
+ return list;
+ }
+
+ /// <summary>
+ /// Gets the current playlist considering the shuffle mode.
+ /// </summary>
+ /// <returns>The playlist.</returns>
+ private List<QueueItem> GetPlaylistInternal()
+ {
+ if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+ {
+ return ShuffledPlaylist;
+ }
+ else
+ {
+ return SortedPlaylist;
+ }
+ }
+
+ /// <summary>
+ /// Gets the current playing item, depending on the shuffle mode.
+ /// </summary>
+ /// <returns>The playing item.</returns>
+ private QueueItem GetPlayingItem()
+ {
+ if (PlayingItemIndex == NoPlayingItemIndex)
+ {
+ return null;
+ }
+ else if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+ {
+ return ShuffledPlaylist[PlayingItemIndex];
+ }
+ else
+ {
+ return SortedPlaylist[PlayingItemIndex];
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs
new file mode 100644
index 000000000..38c9e8e20
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs
@@ -0,0 +1,29 @@
+using System;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Requests
+{
+ /// <summary>
+ /// Class JoinGroupRequest.
+ /// </summary>
+ public class JoinGroupRequest : ISyncPlayRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JoinGroupRequest"/> class.
+ /// </summary>
+ /// <param name="groupId">The identifier of the group to join.</param>
+ public JoinGroupRequest(Guid groupId)
+ {
+ GroupId = groupId;
+ }
+
+ /// <summary>
+ /// Gets the group identifier.
+ /// </summary>
+ /// <value>The identifier of the group to join.</value>
+ public Guid GroupId { get; }
+
+ /// <inheritdoc />
+ public RequestType Type { get; } = RequestType.JoinGroup;
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs
new file mode 100644
index 000000000..545778264
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs
@@ -0,0 +1,13 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Requests
+{
+ /// <summary>
+ /// Class LeaveGroupRequest.
+ /// </summary>
+ public class LeaveGroupRequest : ISyncPlayRequest
+ {
+ /// <inheritdoc />
+ public RequestType Type { get; } = RequestType.LeaveGroup;
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs
new file mode 100644
index 000000000..4a234fdab
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs
@@ -0,0 +1,13 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Requests
+{
+ /// <summary>
+ /// Class ListGroupsRequest.
+ /// </summary>
+ public class ListGroupsRequest : ISyncPlayRequest
+ {
+ /// <inheritdoc />
+ public RequestType Type { get; } = RequestType.ListGroups;
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs
new file mode 100644
index 000000000..1321f0de8
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs
@@ -0,0 +1,28 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Requests
+{
+ /// <summary>
+ /// Class NewGroupRequest.
+ /// </summary>
+ public class NewGroupRequest : ISyncPlayRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NewGroupRequest"/> class.
+ /// </summary>
+ /// <param name="groupName">The name of the new group.</param>
+ public NewGroupRequest(string groupName)
+ {
+ GroupName = groupName;
+ }
+
+ /// <summary>
+ /// Gets the group name.
+ /// </summary>
+ /// <value>The name of the new group.</value>
+ public string GroupName { get; }
+
+ /// <inheritdoc />
+ public RequestType Type { get; } = RequestType.NewGroup;
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs b/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs
new file mode 100644
index 000000000..8c0960b83
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Class GroupInfoDto.
+ /// </summary>
+ public class GroupInfoDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="GroupInfoDto"/> class.
+ /// </summary>
+ /// <param name="groupId">The group identifier.</param>
+ /// <param name="groupName">The group name.</param>
+ /// <param name="state">The group state.</param>
+ /// <param name="participants">The participants.</param>
+ /// <param name="lastUpdatedAt">The date when this DTO has been created.</param>
+ public GroupInfoDto(Guid groupId, string groupName, GroupStateType state, IReadOnlyList<string> participants, DateTime lastUpdatedAt)
+ {
+ GroupId = groupId;
+ GroupName = groupName;
+ State = state;
+ Participants = participants;
+ LastUpdatedAt = lastUpdatedAt;
+ }
+
+ /// <summary>
+ /// Gets the group identifier.
+ /// </summary>
+ /// <value>The group identifier.</value>
+ public Guid GroupId { get; }
+
+ /// <summary>
+ /// Gets the group name.
+ /// </summary>
+ /// <value>The group name.</value>
+ public string GroupName { get; }
+
+ /// <summary>
+ /// Gets the group state.
+ /// </summary>
+ /// <value>The group state.</value>
+ public GroupStateType State { get; }
+
+ /// <summary>
+ /// Gets the participants.
+ /// </summary>
+ /// <value>The participants.</value>
+ public IReadOnlyList<string> Participants { get; }
+
+ /// <summary>
+ /// Gets the date when this DTO has been created.
+ /// </summary>
+ /// <value>The date when this DTO has been created.</value>
+ public DateTime LastUpdatedAt { get; }
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupInfoView.cs b/MediaBrowser.Model/SyncPlay/GroupInfoView.cs
deleted file mode 100644
index f4c685998..000000000
--- a/MediaBrowser.Model/SyncPlay/GroupInfoView.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-#nullable disable
-
-using System.Collections.Generic;
-
-namespace MediaBrowser.Model.SyncPlay
-{
- /// <summary>
- /// Class GroupInfoView.
- /// </summary>
- public class GroupInfoView
- {
- /// <summary>
- /// Gets or sets the group identifier.
- /// </summary>
- /// <value>The group identifier.</value>
- public string GroupId { get; set; }
-
- /// <summary>
- /// Gets or sets the playing item id.
- /// </summary>
- /// <value>The playing item id.</value>
- public string PlayingItemId { get; set; }
-
- /// <summary>
- /// Gets or sets the playing item name.
- /// </summary>
- /// <value>The playing item name.</value>
- public string PlayingItemName { get; set; }
-
- /// <summary>
- /// Gets or sets the position ticks.
- /// </summary>
- /// <value>The position ticks.</value>
- public long PositionTicks { get; set; }
-
- /// <summary>
- /// Gets or sets the participants.
- /// </summary>
- /// <value>The participants.</value>
- public IReadOnlyList<string> Participants { get; set; }
- }
-}
diff --git a/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs b/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs
new file mode 100644
index 000000000..5c9c2627b
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs
@@ -0,0 +1,18 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Enum GroupQueueMode.
+ /// </summary>
+ public enum GroupQueueMode
+ {
+ /// <summary>
+ /// Insert items at the end of the queue.
+ /// </summary>
+ Queue = 0,
+
+ /// <summary>
+ /// Insert items after the currently playing item.
+ /// </summary>
+ QueueNext = 1
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs b/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs
new file mode 100644
index 000000000..4895e57b7
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs
@@ -0,0 +1,23 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Enum GroupRepeatMode.
+ /// </summary>
+ public enum GroupRepeatMode
+ {
+ /// <summary>
+ /// Repeat one item only.
+ /// </summary>
+ RepeatOne = 0,
+
+ /// <summary>
+ /// Cycle the playlist.
+ /// </summary>
+ RepeatAll = 1,
+
+ /// <summary>
+ /// Do not repeat.
+ /// </summary>
+ RepeatNone = 2
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs b/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs
new file mode 100644
index 000000000..de860883c
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs
@@ -0,0 +1,18 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Enum GroupShuffleMode.
+ /// </summary>
+ public enum GroupShuffleMode
+ {
+ /// <summary>
+ /// Sorted playlist.
+ /// </summary>
+ Sorted = 0,
+
+ /// <summary>
+ /// Shuffled playlist.
+ /// </summary>
+ Shuffle = 1
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupStateType.cs b/MediaBrowser.Model/SyncPlay/GroupStateType.cs
new file mode 100644
index 000000000..7aa454f92
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/GroupStateType.cs
@@ -0,0 +1,28 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Enum GroupState.
+ /// </summary>
+ public enum GroupStateType
+ {
+ /// <summary>
+ /// The group is in idle state. No media is playing.
+ /// </summary>
+ Idle = 0,
+
+ /// <summary>
+ /// The group is in wating state. Playback is paused. Will start playing when users are ready.
+ /// </summary>
+ Waiting = 1,
+
+ /// <summary>
+ /// The group is in paused state. Playback is paused. Will resume on play command.
+ /// </summary>
+ Paused = 2,
+
+ /// <summary>
+ /// The group is in playing state. Playback is advancing.
+ /// </summary>
+ Playing = 3
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs
new file mode 100644
index 000000000..7f7deb86b
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs
@@ -0,0 +1,31 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Class GroupStateUpdate.
+ /// </summary>
+ public class GroupStateUpdate
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="GroupStateUpdate"/> class.
+ /// </summary>
+ /// <param name="state">The state of the group.</param>
+ /// <param name="reason">The reason of the state change.</param>
+ public GroupStateUpdate(GroupStateType state, PlaybackRequestType reason)
+ {
+ State = state;
+ Reason = reason;
+ }
+
+ /// <summary>
+ /// Gets the state of the group.
+ /// </summary>
+ /// <value>The state of the group.</value>
+ public GroupStateType State { get; }
+
+ /// <summary>
+ /// Gets the reason of the state change.
+ /// </summary>
+ /// <value>The reason of the state change.</value>
+ public PlaybackRequestType Reason { get; }
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs
index 8c7208211..6f159d653 100644
--- a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs
+++ b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs
@@ -1,28 +1,42 @@
-#nullable disable
+using System;
namespace MediaBrowser.Model.SyncPlay
{
/// <summary>
/// Class GroupUpdate.
/// </summary>
+ /// <typeparam name="T">The type of the data of the message.</typeparam>
public class GroupUpdate<T>
{
/// <summary>
- /// Gets or sets the group identifier.
+ /// Initializes a new instance of the <see cref="GroupUpdate{T}"/> class.
+ /// </summary>
+ /// <param name="groupId">The group identifier.</param>
+ /// <param name="type">The update type.</param>
+ /// <param name="data">The update data.</param>
+ public GroupUpdate(Guid groupId, GroupUpdateType type, T data)
+ {
+ GroupId = groupId;
+ Type = type;
+ Data = data;
+ }
+
+ /// <summary>
+ /// Gets the group identifier.
/// </summary>
/// <value>The group identifier.</value>
- public string GroupId { get; set; }
+ public Guid GroupId { get; }
/// <summary>
- /// Gets or sets the update type.
+ /// Gets the update type.
/// </summary>
/// <value>The update type.</value>
- public GroupUpdateType Type { get; set; }
+ public GroupUpdateType Type { get; }
/// <summary>
- /// Gets or sets the data.
+ /// Gets the update data.
/// </summary>
- /// <value>The data.</value>
- public T Data { get; set; }
+ /// <value>The update data.</value>
+ public T Data { get; }
}
}
diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
index c749f7b13..907d1defe 100644
--- a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
+++ b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
@@ -26,14 +26,14 @@ namespace MediaBrowser.Model.SyncPlay
GroupLeft,
/// <summary>
- /// The group-wait update. Tells members of the group that a user is buffering.
+ /// The group-state update. Tells members of the group that the state changed.
/// </summary>
- GroupWait,
+ StateUpdate,
/// <summary>
- /// The prepare-session update. Tells a user to load some content.
+ /// The play-queue update. Tells a user the playing queue of the group.
/// </summary>
- PrepareSession,
+ PlayQueue,
/// <summary>
/// The not-in-group error. Tells a user that they don't belong to a group.
diff --git a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs b/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs
deleted file mode 100644
index 0c77a6132..000000000
--- a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System;
-
-namespace MediaBrowser.Model.SyncPlay
-{
- /// <summary>
- /// Class JoinGroupRequest.
- /// </summary>
- public class JoinGroupRequest
- {
- /// <summary>
- /// Gets or sets the Group id.
- /// </summary>
- /// <value>The Group id to join.</value>
- public Guid GroupId { get; set; }
- }
-}
diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs
new file mode 100644
index 000000000..a851229f7
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Class PlayQueueUpdate.
+ /// </summary>
+ public class PlayQueueUpdate
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlayQueueUpdate"/> class.
+ /// </summary>
+ /// <param name="reason">The reason for the update.</param>
+ /// <param name="lastUpdate">The UTC time of the last change to the playing queue.</param>
+ /// <param name="playlist">The playlist.</param>
+ /// <param name="playingItemIndex">The playing item index in the playlist.</param>
+ /// <param name="startPositionTicks">The start position ticks.</param>
+ /// <param name="shuffleMode">The shuffle mode.</param>
+ /// <param name="repeatMode">The repeat mode.</param>
+ public PlayQueueUpdate(PlayQueueUpdateReason reason, DateTime lastUpdate, IReadOnlyList<QueueItem> playlist, int playingItemIndex, long startPositionTicks, GroupShuffleMode shuffleMode, GroupRepeatMode repeatMode)
+ {
+ Reason = reason;
+ LastUpdate = lastUpdate;
+ Playlist = playlist;
+ PlayingItemIndex = playingItemIndex;
+ StartPositionTicks = startPositionTicks;
+ ShuffleMode = shuffleMode;
+ RepeatMode = repeatMode;
+ }
+
+ /// <summary>
+ /// Gets the request type that originated this update.
+ /// </summary>
+ /// <value>The reason for the update.</value>
+ public PlayQueueUpdateReason Reason { get; }
+
+ /// <summary>
+ /// Gets the UTC time of the last change to the playing queue.
+ /// </summary>
+ /// <value>The UTC time of the last change to the playing queue.</value>
+ public DateTime LastUpdate { get; }
+
+ /// <summary>
+ /// Gets the playlist.
+ /// </summary>
+ /// <value>The playlist.</value>
+ public IReadOnlyList<QueueItem> Playlist { get; }
+
+ /// <summary>
+ /// Gets the playing item index in the playlist.
+ /// </summary>
+ /// <value>The playing item index in the playlist.</value>
+ public int PlayingItemIndex { get; }
+
+ /// <summary>
+ /// Gets the start position ticks.
+ /// </summary>
+ /// <value>The start position ticks.</value>
+ public long StartPositionTicks { get; }
+
+ /// <summary>
+ /// Gets the shuffle mode.
+ /// </summary>
+ /// <value>The shuffle mode.</value>
+ public GroupShuffleMode ShuffleMode { get; }
+
+ /// <summary>
+ /// Gets the repeat mode.
+ /// </summary>
+ /// <value>The repeat mode.</value>
+ public GroupRepeatMode RepeatMode { get; }
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs
new file mode 100644
index 000000000..b609f4b1b
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs
@@ -0,0 +1,58 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Enum PlayQueueUpdateReason.
+ /// </summary>
+ public enum PlayQueueUpdateReason
+ {
+ /// <summary>
+ /// A user is requesting to play a new playlist.
+ /// </summary>
+ NewPlaylist = 0,
+
+ /// <summary>
+ /// A user is changing the playing item.
+ /// </summary>
+ SetCurrentItem = 1,
+
+ /// <summary>
+ /// A user is removing items from the playlist.
+ /// </summary>
+ RemoveItems = 2,
+
+ /// <summary>
+ /// A user is moving an item in the playlist.
+ /// </summary>
+ MoveItem = 3,
+
+ /// <summary>
+ /// A user is adding items the queue.
+ /// </summary>
+ Queue = 4,
+
+ /// <summary>
+ /// A user is adding items to the queue, after the currently playing item.
+ /// </summary>
+ QueueNext = 5,
+
+ /// <summary>
+ /// A user is requesting the next item in queue.
+ /// </summary>
+ NextItem = 6,
+
+ /// <summary>
+ /// A user is requesting the previous item in queue.
+ /// </summary>
+ PreviousItem = 7,
+
+ /// <summary>
+ /// A user is changing repeat mode.
+ /// </summary>
+ RepeatMode = 8,
+
+ /// <summary>
+ /// A user is changing shuffle mode.
+ /// </summary>
+ ShuffleMode = 9
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs
deleted file mode 100644
index 9de23194e..000000000
--- a/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using System;
-
-namespace MediaBrowser.Model.SyncPlay
-{
- /// <summary>
- /// Class PlaybackRequest.
- /// </summary>
- public class PlaybackRequest
- {
- /// <summary>
- /// Gets or sets the request type.
- /// </summary>
- /// <value>The request type.</value>
- public PlaybackRequestType Type { get; set; }
-
- /// <summary>
- /// Gets or sets when the request has been made by the client.
- /// </summary>
- /// <value>The date of the request.</value>
- public DateTime? When { get; set; }
-
- /// <summary>
- /// Gets or sets the position ticks.
- /// </summary>
- /// <value>The position ticks.</value>
- public long? PositionTicks { get; set; }
-
- /// <summary>
- /// Gets or sets the ping time.
- /// </summary>
- /// <value>The ping time.</value>
- public long? Ping { get; set; }
- }
-}
diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
index e89efeed8..4429623dd 100644
--- a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
+++ b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
@@ -6,33 +6,88 @@ namespace MediaBrowser.Model.SyncPlay
public enum PlaybackRequestType
{
/// <summary>
- /// A user is requesting a play command for the group.
+ /// A user is setting a new playlist.
/// </summary>
Play = 0,
/// <summary>
+ /// A user is changing the playlist item.
+ /// </summary>
+ SetPlaylistItem = 1,
+
+ /// <summary>
+ /// A user is removing items from the playlist.
+ /// </summary>
+ RemoveFromPlaylist = 2,
+
+ /// <summary>
+ /// A user is moving an item in the playlist.
+ /// </summary>
+ MovePlaylistItem = 3,
+
+ /// <summary>
+ /// A user is adding items to the playlist.
+ /// </summary>
+ Queue = 4,
+
+ /// <summary>
+ /// A user is requesting an unpause command for the group.
+ /// </summary>
+ Unpause = 5,
+
+ /// <summary>
/// A user is requesting a pause command for the group.
/// </summary>
- Pause = 1,
+ Pause = 6,
/// <summary>
- /// A user is requesting a seek command for the group.
+ /// A user is requesting a stop command for the group.
/// </summary>
- Seek = 2,
+ Stop = 7,
/// <summary>
+ /// A user is requesting a seek command for the group.
+ /// </summary>
+ Seek = 8,
+
+ /// <summary>
/// A user is signaling that playback is buffering.
/// </summary>
- Buffer = 3,
+ Buffer = 9,
/// <summary>
/// A user is signaling that playback resumed.
/// </summary>
- Ready = 4,
+ Ready = 10,
+
+ /// <summary>
+ /// A user is requesting next item in playlist.
+ /// </summary>
+ NextItem = 11,
+
+ /// <summary>
+ /// A user is requesting previous item in playlist.
+ /// </summary>
+ PreviousItem = 12,
+
+ /// <summary>
+ /// A user is setting the repeat mode.
+ /// </summary>
+ SetRepeatMode = 13,
+
+ /// <summary>
+ /// A user is setting the shuffle mode.
+ /// </summary>
+ SetShuffleMode = 14,
+
+ /// <summary>
+ /// A user is reporting their ping.
+ /// </summary>
+ Ping = 15,
/// <summary>
- /// A user is reporting its ping.
+ /// A user is requesting to be ignored on group wait.
/// </summary>
- Ping = 5
+ IgnoreWait = 16
}
}
diff --git a/MediaBrowser.Model/SyncPlay/QueueItem.cs b/MediaBrowser.Model/SyncPlay/QueueItem.cs
new file mode 100644
index 000000000..a6dcc109e
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/QueueItem.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Class QueueItem.
+ /// </summary>
+ public class QueueItem
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="QueueItem"/> class.
+ /// </summary>
+ /// <param name="itemId">The item identifier.</param>
+ public QueueItem(Guid itemId)
+ {
+ ItemId = itemId;
+ }
+
+ /// <summary>
+ /// Gets the item identifier.
+ /// </summary>
+ /// <value>The item identifier.</value>
+ public Guid ItemId { get; }
+
+ /// <summary>
+ /// Gets the playlist identifier of the item.
+ /// </summary>
+ /// <value>The playlist identifier of the item.</value>
+ public Guid PlaylistItemId { get; } = Guid.NewGuid();
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/RequestType.cs b/MediaBrowser.Model/SyncPlay/RequestType.cs
new file mode 100644
index 000000000..a6e397dcd
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/RequestType.cs
@@ -0,0 +1,33 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Enum RequestType.
+ /// </summary>
+ public enum RequestType
+ {
+ /// <summary>
+ /// A user is requesting to create a new group.
+ /// </summary>
+ NewGroup = 0,
+
+ /// <summary>
+ /// A user is requesting to join a group.
+ /// </summary>
+ JoinGroup = 1,
+
+ /// <summary>
+ /// A user is requesting to leave a group.
+ /// </summary>
+ LeaveGroup = 2,
+
+ /// <summary>
+ /// A user is requesting the list of available groups.
+ /// </summary>
+ ListGroups = 3,
+
+ /// <summary>
+ /// A user is sending a playback command to a group.
+ /// </summary>
+ Playback = 4
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/SendCommand.cs b/MediaBrowser.Model/SyncPlay/SendCommand.cs
index 0f0be0152..73cb50876 100644
--- a/MediaBrowser.Model/SyncPlay/SendCommand.cs
+++ b/MediaBrowser.Model/SyncPlay/SendCommand.cs
@@ -1,4 +1,4 @@
-#nullable disable
+using System;
namespace MediaBrowser.Model.SyncPlay
{
@@ -8,33 +8,58 @@ namespace MediaBrowser.Model.SyncPlay
public class SendCommand
{
/// <summary>
- /// Gets or sets the group identifier.
+ /// Initializes a new instance of the <see cref="SendCommand"/> class.
+ /// </summary>
+ /// <param name="groupId">The group identifier.</param>
+ /// <param name="playlistItemId">The playlist identifier of the playing item.</param>
+ /// <param name="when">The UTC time when to execute the command.</param>
+ /// <param name="command">The command.</param>
+ /// <param name="positionTicks">The position ticks, for commands that require it.</param>
+ /// <param name="emittedAt">The UTC time when this command has been emitted.</param>
+ public SendCommand(Guid groupId, Guid playlistItemId, DateTime when, SendCommandType command, long? positionTicks, DateTime emittedAt)
+ {
+ GroupId = groupId;
+ PlaylistItemId = playlistItemId;
+ When = when;
+ Command = command;
+ PositionTicks = positionTicks;
+ EmittedAt = emittedAt;
+ }
+
+ /// <summary>
+ /// Gets the group identifier.
/// </summary>
/// <value>The group identifier.</value>
- public string GroupId { get; set; }
+ public Guid GroupId { get; }
+
+ /// <summary>
+ /// Gets the playlist identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist identifier of the playing item.</value>
+ public Guid PlaylistItemId { get; }
/// <summary>
/// Gets or sets the UTC time when to execute the command.
/// </summary>
/// <value>The UTC time when to execute the command.</value>
- public string When { get; set; }
+ public DateTime When { get; set; }
/// <summary>
- /// Gets or sets the position ticks.
+ /// Gets the position ticks.
/// </summary>
/// <value>The position ticks.</value>
- public long? PositionTicks { get; set; }
+ public long? PositionTicks { get; }
/// <summary>
- /// Gets or sets the command.
+ /// Gets the command.
/// </summary>
/// <value>The command.</value>
- public SendCommandType Command { get; set; }
+ public SendCommandType Command { get; }
/// <summary>
- /// Gets or sets the UTC time when this command has been emitted.
+ /// Gets the UTC time when this command has been emitted.
/// </summary>
/// <value>The UTC time when this command has been emitted.</value>
- public string EmittedAt { get; set; }
+ public DateTime EmittedAt { get; }
}
}
diff --git a/MediaBrowser.Model/SyncPlay/SendCommandType.cs b/MediaBrowser.Model/SyncPlay/SendCommandType.cs
index 86dec9e90..e6b17c60a 100644
--- a/MediaBrowser.Model/SyncPlay/SendCommandType.cs
+++ b/MediaBrowser.Model/SyncPlay/SendCommandType.cs
@@ -6,9 +6,9 @@ namespace MediaBrowser.Model.SyncPlay
public enum SendCommandType
{
/// <summary>
- /// The play command. Instructs users to start playback.
+ /// The unpause command. Instructs users to unpause playback.
/// </summary>
- Play = 0,
+ Unpause = 0,
/// <summary>
/// The pause command. Instructs users to pause playback.
@@ -16,8 +16,13 @@ namespace MediaBrowser.Model.SyncPlay
Pause = 1,
/// <summary>
+ /// The stop command. Instructs users to stop playback.
+ /// </summary>
+ Stop = 2,
+
+ /// <summary>
/// The seek command. Instructs users to seek to a specified time.
/// </summary>
- Seek = 2
+ Seek = 3
}
}
diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs b/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs
new file mode 100644
index 000000000..29dbb11b3
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs
@@ -0,0 +1,28 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Used to filter the sessions of a group.
+ /// </summary>
+ public enum SyncPlayBroadcastType
+ {
+ /// <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
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs b/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs
index 8ec5eaab3..219e7b1e0 100644
--- a/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs
+++ b/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs
@@ -1,4 +1,4 @@
-#nullable disable
+using System;
namespace MediaBrowser.Model.SyncPlay
{
@@ -8,15 +8,26 @@ namespace MediaBrowser.Model.SyncPlay
public class UtcTimeResponse
{
/// <summary>
- /// Gets or sets the UTC time when request has been received.
+ /// Initializes a new instance of the <see cref="UtcTimeResponse"/> class.
+ /// </summary>
+ /// <param name="requestReceptionTime">The UTC time when request has been received.</param>
+ /// <param name="responseTransmissionTime">The UTC time when response has been sent.</param>
+ public UtcTimeResponse(DateTime requestReceptionTime, DateTime responseTransmissionTime)
+ {
+ RequestReceptionTime = requestReceptionTime;
+ ResponseTransmissionTime = responseTransmissionTime;
+ }
+
+ /// <summary>
+ /// Gets the UTC time when request has been received.
/// </summary>
/// <value>The UTC time when request has been received.</value>
- public string RequestReceptionTime { get; set; }
+ public DateTime RequestReceptionTime { get; }
/// <summary>
- /// Gets or sets the UTC time when response has been sent.
+ /// Gets the UTC time when response has been sent.
/// </summary>
/// <value>The UTC time when response has been sent.</value>
- public string ResponseTransmissionTime { get; set; }
+ public DateTime ResponseTransmissionTime { get; }
}
}