< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.SyncPlay.SyncPlayManager
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
Line coverage
73%
Covered lines: 124
Uncovered lines: 44
Coverable lines: 168
Total lines: 487
Line coverage: 73.8%
Branch coverage
60%
Covered branches: 47
Total branches: 78
Branch coverage: 60.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/12/2026 - 12:15:57 AM Line coverage: 14.7% (21/142) Branch coverage: 1.4% (1/70) Total lines: 4197/3/2026 - 12:15:32 AM Line coverage: 47.1% (67/142) Branch coverage: 31.4% (22/70) Total lines: 41910/29/2026 - 12:17:11 AM Line coverage: 73.8% (124/168) Branch coverage: 60.2% (47/78) Total lines: 487 5/12/2026 - 12:15:57 AM Line coverage: 14.7% (21/142) Branch coverage: 1.4% (1/70) Total lines: 4197/3/2026 - 12:15:32 AM Line coverage: 47.1% (67/142) Branch coverage: 31.4% (22/70) Total lines: 41910/29/2026 - 12:17:11 AM Line coverage: 73.8% (124/168) Branch coverage: 60.2% (47/78) Total lines: 487

Coverage delta

Coverage delta 33 -33

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Dispose()100%11100%
NewGroup(...)50%9873.68%
JoinGroup(...)57.14%321455.17%
LeaveGroup(...)58.33%191263.63%
ListGroups(...)75%8883.33%
GetGroup(...)50%6677.77%
HandleRequest(...)50%221258.82%
IsUserActive(...)100%22100%
Dispose(...)50%2285.71%
UpdateGroupWaitTimer()75%4480%
OnGroupWaitTimerTick()75%4469.23%
OnSessionEnded(...)50%22100%
UpdateSessionsCounter(...)75%4488.88%

File(s)

/srv/git/jellyfin/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs

#LineLine coverage
 1#nullable disable
 2
 3using System;
 4using System.Collections.Concurrent;
 5using System.Collections.Generic;
 6using System.Threading;
 7using MediaBrowser.Controller.Library;
 8using MediaBrowser.Controller.Session;
 9using MediaBrowser.Controller.SyncPlay;
 10using MediaBrowser.Controller.SyncPlay.Requests;
 11using MediaBrowser.Model.SyncPlay;
 12using Microsoft.Extensions.Logging;
 13
 14namespace Emby.Server.Implementations.SyncPlay
 15{
 16    /// <summary>
 17    /// Class SyncPlayManager.
 18    /// </summary>
 19    public class SyncPlayManager : ISyncPlayManager, IDisposable
 20    {
 21        /// <summary>
 22        /// How often, in milliseconds, the groups are checked for a spent wait deadline.
 23        /// </summary>
 24        private const int GroupWaitSweepInterval = 1000;
 25
 26        /// <summary>
 27        /// The logger.
 28        /// </summary>
 29        private readonly ILogger<SyncPlayManager> _logger;
 30
 31        /// <summary>
 32        /// The logger factory.
 33        /// </summary>
 34        private readonly ILoggerFactory _loggerFactory;
 35
 36        /// <summary>
 37        /// The user manager.
 38        /// </summary>
 39        private readonly IUserManager _userManager;
 40
 41        /// <summary>
 42        /// The session manager.
 43        /// </summary>
 44        private readonly ISessionManager _sessionManager;
 45
 46        /// <summary>
 47        /// The library manager.
 48        /// </summary>
 49        private readonly ILibraryManager _libraryManager;
 50
 51        /// <summary>
 52        /// The map between users and counter of active sessions.
 53        /// </summary>
 2554        private readonly ConcurrentDictionary<Guid, int> _activeUsers =
 2555            new ConcurrentDictionary<Guid, int>();
 56
 57        /// <summary>
 58        /// The map between sessions and groups.
 59        /// </summary>
 2560        private readonly ConcurrentDictionary<string, Group> _sessionToGroupMap =
 2561            new ConcurrentDictionary<string, Group>(StringComparer.OrdinalIgnoreCase);
 62
 63        /// <summary>
 64        /// The groups.
 65        /// </summary>
 2566        private readonly ConcurrentDictionary<Guid, Group> _groups =
 2567            new ConcurrentDictionary<Guid, Group>();
 68
 69        /// <summary>
 70        /// Lock used for accessing multiple groups at once.
 71        /// </summary>
 72        /// <remarks>
 73        /// This lock has priority on locks made on <see cref="Group"/>.
 74        /// </remarks>
 2575        private readonly Lock _groupsLock = new();
 76
 77        /// <summary>
 78        /// The timer that watches the groups' wait deadlines, running only while there are groups.
 79        /// </summary>
 80        private readonly Timer _groupWaitTimer;
 81
 82        private bool _disposed = false;
 83
 84        /// <summary>
 85        /// Initializes a new instance of the <see cref="SyncPlayManager" /> class.
 86        /// </summary>
 87        /// <param name="loggerFactory">The logger factory.</param>
 88        /// <param name="userManager">The user manager.</param>
 89        /// <param name="sessionManager">The session manager.</param>
 90        /// <param name="libraryManager">The library manager.</param>
 91        public SyncPlayManager(
 92            ILoggerFactory loggerFactory,
 93            IUserManager userManager,
 94            ISessionManager sessionManager,
 95            ILibraryManager libraryManager)
 96        {
 2597            _loggerFactory = loggerFactory;
 2598            _userManager = userManager;
 2599            _sessionManager = sessionManager;
 25100            _libraryManager = libraryManager;
 25101            _logger = loggerFactory.CreateLogger<SyncPlayManager>();
 25102            _sessionManager.SessionEnded += OnSessionEnded;
 103            _groupWaitTimer = new Timer(_ => OnGroupWaitTimerTick(), null, Timeout.Infinite, Timeout.Infinite);
 25104        }
 105
 106        /// <summary>
 107        /// Gets the maximum time, in milliseconds, a group waits for its members to report ready.
 108        /// </summary>
 109        /// <value>The group-wait timeout.</value>
 25110        internal long GroupWaitTimeout { get; init; } = Group.DefaultGroupWaitTimeout;
 111
 112        /// <inheritdoc />
 113        public void Dispose()
 114        {
 21115            Dispose(true);
 21116            GC.SuppressFinalize(this);
 21117        }
 118
 119        /// <inheritdoc />
 120        public GroupInfoDto NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken)
 121        {
 5122            if (session is null)
 123            {
 0124                throw new InvalidOperationException("Session is null!");
 125            }
 126
 5127            if (request is null)
 128            {
 0129                throw new InvalidOperationException("Request is null!");
 130            }
 131
 132            // Locking required to access list of groups.
 133            lock (_groupsLock)
 134            {
 135                // Make sure that session has not joined another group.
 5136                if (_sessionToGroupMap.ContainsKey(session.Id))
 137                {
 0138                    var leaveGroupRequest = new LeaveGroupRequest();
 0139                    LeaveGroup(session, leaveGroupRequest, cancellationToken);
 140                }
 141
 5142                var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager)
 5143                {
 5144                    GroupWaitTimeout = GroupWaitTimeout
 5145                };
 5146                _groups[group.GroupId] = group;
 5147                UpdateGroupWaitTimer();
 148
 5149                if (!_sessionToGroupMap.TryAdd(session.Id, group))
 150                {
 0151                    throw new InvalidOperationException("Could not add session to group!");
 152                }
 153
 5154                UpdateSessionsCounter(session.UserId, 1);
 5155                group.CreateGroup(session, request, cancellationToken);
 5156                return group.GetInfo();
 157            }
 5158        }
 159
 160        /// <inheritdoc />
 161        public void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
 162        {
 3163            if (session is null)
 164            {
 0165                throw new InvalidOperationException("Session is null!");
 166            }
 167
 3168            if (request is null)
 169            {
 0170                throw new InvalidOperationException("Request is null!");
 171            }
 172
 3173            var user = _userManager.GetUserById(session.UserId);
 174
 175            // Locking required to access list of groups.
 176            lock (_groupsLock)
 177            {
 3178                _groups.TryGetValue(request.GroupId, out Group group);
 179
 3180                if (group is null)
 181                {
 0182                    _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session
 183
 0184                    var error = new SyncPlayGroupDoesNotExistUpdate(Guid.Empty, string.Empty);
 0185                    _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
 0186                    return;
 187                }
 188
 189                // Group lock required to let other requests end first.
 3190                lock (group)
 191                {
 3192                    if (!group.HasAccessToPlayQueue(user))
 193                    {
 0194                        _logger.LogWarning("Session {SessionId} tried to join group {GroupId} but does not have access t
 195
 0196                        var error = new SyncPlayLibraryAccessDeniedUpdate(group.GroupId, string.Empty);
 0197                        _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
 0198                        return;
 199                    }
 200
 3201                    if (_sessionToGroupMap.TryGetValue(session.Id, out var existingGroup))
 202                    {
 1203                        if (existingGroup.GroupId.Equals(request.GroupId))
 204                        {
 205                            // Restore session. The session is already in the group and has already
 206                            // been counted, so the counter must not be incremented a second time.
 1207                            group.SessionJoin(session, request, cancellationToken);
 1208                            return;
 209                        }
 210
 0211                        var leaveGroupRequest = new LeaveGroupRequest();
 0212                        LeaveGroup(session, leaveGroupRequest, cancellationToken);
 213                    }
 214
 2215                    if (!_sessionToGroupMap.TryAdd(session.Id, group))
 216                    {
 0217                        throw new InvalidOperationException("Could not add session to group!");
 218                    }
 219
 2220                    UpdateSessionsCounter(session.UserId, 1);
 2221                    group.SessionJoin(session, request, cancellationToken);
 2222                }
 223            }
 3224        }
 225
 226        /// <inheritdoc />
 227        public void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken)
 228        {
 5229            if (session is null)
 230            {
 0231                throw new InvalidOperationException("Session is null!");
 232            }
 233
 5234            if (request is null)
 235            {
 0236                throw new InvalidOperationException("Request is null!");
 237            }
 238
 239            // Locking required to access list of groups.
 240            lock (_groupsLock)
 241            {
 5242                if (_sessionToGroupMap.TryGetValue(session.Id, out var group))
 243                {
 244                    // Group lock required to let other requests end first.
 5245                    lock (group)
 246                    {
 5247                        if (_sessionToGroupMap.TryRemove(session.Id, out var tempGroup))
 248                        {
 5249                            if (!tempGroup.GroupId.Equals(group.GroupId))
 250                            {
 0251                                throw new InvalidOperationException("Session was in wrong group!");
 252                            }
 253                        }
 254                        else
 255                        {
 0256                            throw new InvalidOperationException("Could not remove session from group!");
 257                        }
 258
 5259                        UpdateSessionsCounter(session.UserId, -1);
 5260                        group.SessionLeave(session, request, cancellationToken);
 261
 5262                        if (group.IsGroupEmpty())
 263                        {
 4264                            _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId);
 4265                            _groups.Remove(group.GroupId, out _);
 4266                            UpdateGroupWaitTimer();
 267                        }
 5268                    }
 269                }
 270                else
 271                {
 0272                    _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id);
 273
 0274                    var error = new SyncPlayNotInGroupUpdate(Guid.Empty, string.Empty);
 0275                    _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
 276                }
 0277            }
 5278        }
 279
 280        /// <inheritdoc />
 281        public List<GroupInfoDto> ListGroups(SessionInfo session, ListGroupsRequest request)
 282        {
 23283            if (session is null)
 284            {
 0285                throw new InvalidOperationException("Session is null!");
 286            }
 287
 23288            if (request is null)
 289            {
 0290                throw new InvalidOperationException("Request is null!");
 291            }
 292
 23293            var user = _userManager.GetUserById(session.UserId);
 23294            List<GroupInfoDto> list = new List<GroupInfoDto>();
 295
 296            lock (_groupsLock)
 297            {
 90298                foreach (var (_, group) in _groups)
 299                {
 300                    // Locking required as group is not thread-safe.
 22301                    lock (group)
 302                    {
 22303                        if (group.HasAccessToPlayQueue(user))
 304                        {
 22305                            list.Add(group.GetInfo());
 306                        }
 22307                    }
 308                }
 309            }
 310
 23311            return list;
 312        }
 313
 314        /// <inheritdoc />
 315        public GroupInfoDto GetGroup(SessionInfo session, Guid groupId)
 316        {
 50317            ArgumentNullException.ThrowIfNull(session);
 318
 50319            var user = _userManager.GetUserById(session.UserId);
 320
 321            lock (_groupsLock)
 322            {
 150323                foreach (var (_, group) in _groups)
 324                {
 325                    // Locking required as group is not thread-safe.
 50326                    lock (group)
 327                    {
 50328                        if (group.GroupId.Equals(groupId) && group.HasAccessToPlayQueue(user))
 329                        {
 50330                            return group.GetInfo();
 331                        }
 0332                    }
 333                }
 334            }
 335
 0336            return null;
 50337        }
 338
 339        /// <inheritdoc />
 340        public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToke
 341        {
 1342            if (session is null)
 343            {
 0344                throw new InvalidOperationException("Session is null!");
 345            }
 346
 1347            if (request is null)
 348            {
 0349                throw new InvalidOperationException("Request is null!");
 350            }
 351
 1352            if (_sessionToGroupMap.TryGetValue(session.Id, out var group))
 353            {
 354                // Group lock required as Group is not thread-safe.
 1355                lock (group)
 356                {
 357                    // Make sure that session still belongs to this group. The lookup can fail
 358                    // outright when the session left while this request was waiting on the group
 359                    // lock, which is exactly the case this re-check exists to catch.
 1360                    if (!_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup)
 1361                        || !checkGroup.GroupId.Equals(group.GroupId))
 362                    {
 363                        // Drop request.
 0364                        return;
 365                    }
 366
 367                    // Drop request if group is empty.
 1368                    if (group.IsGroupEmpty())
 369                    {
 0370                        return;
 371                    }
 372
 373                    // Apply requested changes to group.
 1374                    group.HandleRequest(session, request, cancellationToken);
 1375                }
 376            }
 377            else
 378            {
 0379                _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id);
 380
 0381                var error = new SyncPlayNotInGroupUpdate(Guid.Empty, string.Empty);
 0382                _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
 383            }
 1384        }
 385
 386        /// <inheritdoc />
 387        public bool IsUserActive(Guid userId)
 388        {
 5389            if (_activeUsers.TryGetValue(userId, out var sessionsCounter))
 390            {
 2391                return sessionsCounter > 0;
 392            }
 393
 3394            return false;
 395        }
 396
 397        /// <summary>
 398        /// Releases unmanaged and optionally managed resources.
 399        /// </summary>
 400        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release
 401        protected virtual void Dispose(bool disposing)
 402        {
 21403            if (_disposed)
 404            {
 0405                return;
 406            }
 407
 21408            _sessionManager.SessionEnded -= OnSessionEnded;
 409
 410            lock (_groupsLock)
 411            {
 21412                _disposed = true;
 21413                _groupWaitTimer.Dispose();
 21414            }
 21415        }
 416
 417        private void UpdateGroupWaitTimer()
 418        {
 9419            if (_disposed)
 420            {
 0421                return;
 422            }
 423
 9424            var interval = _groups.IsEmpty ? Timeout.Infinite : GroupWaitSweepInterval;
 9425            _groupWaitTimer.Change(interval, interval);
 9426        }
 427
 428        private void OnGroupWaitTimerTick()
 429        {
 430            try
 40431            {
 432                lock (_groupsLock)
 433                {
 40434                    if (_disposed)
 435                    {
 0436                        return;
 437                    }
 438
 160439                    foreach (var (_, group) in _groups)
 440                    {
 441                        // Group lock required as Group is not thread-safe.
 40442                        lock (group)
 443                        {
 40444                            group.HandleGroupWaitTimeout(CancellationToken.None);
 40445                        }
 446                    }
 40447                }
 40448            }
 0449            catch (Exception ex)
 450            {
 0451                _logger.LogError(ex, "Error while recovering SyncPlay groups from a timed out wait.");
 0452            }
 40453        }
 454
 455        private void OnSessionEnded(object sender, SessionEventArgs e)
 456        {
 1457            var session = e.SessionInfo;
 458
 1459            if (_sessionToGroupMap.TryGetValue(session.Id, out _))
 460            {
 1461                var leaveGroupRequest = new LeaveGroupRequest();
 1462                LeaveGroup(session, leaveGroupRequest, CancellationToken.None);
 463            }
 1464        }
 465
 466        private void UpdateSessionsCounter(Guid userId, int toAdd)
 467        {
 468            // Update sessions counter.
 12469            var newSessionsCounter = _activeUsers.AddOrUpdate(
 12470                userId,
 12471                toAdd,
 12472                (_, sessionsCounter) => sessionsCounter + toAdd);
 473
 474            // Should never happen.
 12475            if (newSessionsCounter < 0)
 476            {
 0477                throw new InvalidOperationException("Sessions counter is negative!");
 478            }
 479
 480            // Clean record if user has no more active sessions.
 12481            if (newSessionsCounter == 0)
 482            {
 4483                _activeUsers.TryRemove(new KeyValuePair<Guid, int>(userId, newSessionsCounter));
 484            }
 12485        }
 486    }
 487}