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);
}
}
}