Installed .NET 8 SDK (per-user, winget needed admin elevation this
machine doesn't have) and restored the real MediaBrowser.Server.Core /
MediaBrowser.Common packages, then fixed everything the compiler and
a bit of reflection surfaced:
- PlaybackProgressEventArgs/PlaybackStopEventArgs are in
MediaBrowser.Controller.Library, not ...Session
- IAuthorizationContext/AuthorizationInfo are in MediaBrowser.Controller.Net
- Item/user/session ids (SessionInfo.Id/UserId, BaseItemDto.Id) are plain
string on this SDK version, not Guid - Party/PartyMember updated
accordingly (Party.Id stays a Guid; it's minted by this plugin, not Emby)
- Reading the calling user needed an extra step:
IAuthorizationContext.GetAuthorizationInfo(Request).UserId is a `long`
(internal row id); .User.Id is the one matching SessionInfo.UserId,
but it's a Guid so it's reformatted with .ToString("N")
docs/architecture.md's "Assumptions to verify" now reflects what was
actually confirmed vs. what's still only compile-checked (HTTP status
codes, content-type headers, the JS auth header format, the dashboard
config page) pending a run against a real server.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
133 lines
4.4 KiB
C#
133 lines
4.4 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
namespace WatchParty.Parties
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class PartyManager
|
|
{
|
|
private static readonly Lazy<PartyManager> LazyInstance =
|
|
new Lazy<PartyManager>(() => new PartyManager());
|
|
|
|
public static PartyManager Instance => LazyInstance.Value;
|
|
|
|
private readonly ConcurrentDictionary<Guid, Party> _partiesById = new ConcurrentDictionary<Guid, Party>();
|
|
private readonly ConcurrentDictionary<string, Guid> _partyIdByJoinCode =
|
|
new ConcurrentDictionary<string, Guid>(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<Party> 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);
|
|
}
|
|
}
|
|
}
|