using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; namespace WatchParty.Parties { /// /// In-memory store of active parties. Nothing is persisted to disk - a server /// restart clears every party, which is fine for v1 (a party is meant to live /// for one watch session, not persist for weeks). See docs/architecture.md. /// public class PartyManager { private static readonly Lazy LazyInstance = new Lazy(() => new PartyManager()); public static PartyManager Instance => LazyInstance.Value; private readonly ConcurrentDictionary _partiesById = new ConcurrentDictionary(); private readonly ConcurrentDictionary _partyIdByJoinCode = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); public Party Create(string hostUserId, string hostUserName, string hostSessionId, string itemId) { var party = new Party { ItemId = itemId, HostUserId = hostUserId, ReferenceUserId = hostUserId, JoinCode = GenerateJoinCode() }; party.Members[hostUserId] = new PartyMember { UserId = hostUserId, UserName = hostUserName, SessionId = hostSessionId, JoinedUtc = DateTime.UtcNow, LastUpdatedUtc = DateTime.UtcNow }; _partiesById[party.Id] = party; _partyIdByJoinCode[party.JoinCode] = party.Id; return party; } public Party GetById(Guid partyId) { _partiesById.TryGetValue(partyId, out var party); return party; } public Party GetByJoinCode(string joinCode) { if (joinCode != null && _partyIdByJoinCode.TryGetValue(joinCode, out var partyId)) { return GetById(partyId); } return null; } public PartyMember Join(Party party, string userId, string userName, string sessionId) { var member = new PartyMember { UserId = userId, UserName = userName, SessionId = sessionId, JoinedUtc = DateTime.UtcNow, LastUpdatedUtc = DateTime.UtcNow }; party.Members[userId] = member; party.LastActivityUtc = DateTime.UtcNow; return member; } public void Leave(Party party, string userId) { party.Members.TryRemove(userId, out _); party.LastActivityUtc = DateTime.UtcNow; if (party.ReferenceUserId == userId) { // Reference left: fall back to whoever's left (arbitrary but // deterministic-ish; good enough for v1 - see docs/architecture.md). var next = party.Members.Keys.FirstOrDefault(); if (next != null) { party.ReferenceUserId = next; } } if (party.Members.IsEmpty) { _partiesById.TryRemove(party.Id, out _); _partyIdByJoinCode.TryRemove(party.JoinCode, out _); } } public IEnumerable GetAllActive() => _partiesById.Values; public void RemoveIdleParties(int idleTimeoutMinutes) { var cutoff = DateTime.UtcNow.AddMinutes(-idleTimeoutMinutes); foreach (var party in _partiesById.Values.Where(p => p.LastActivityUtc < cutoff).ToList()) { _partiesById.TryRemove(party.Id, out _); _partyIdByJoinCode.TryRemove(party.JoinCode, out _); } } private static string GenerateJoinCode() { const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I var bytes = new byte[6]; using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create()) { rng.GetBytes(bytes); } var chars = new char[6]; for (var i = 0; i < 6; i++) { chars[i] = alphabet[bytes[i] % alphabet.Length]; } return new string(chars); } } }