using Shared; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace WatchTogether { /// /// Server-side state of a room the server eavesdrops on. The server never /// creates rooms: a room exists because a client opened its channel and a /// host announced a stream. Until the host vanishes, the server is a /// silent member; when the host is gone, it seamlessly takes over. /// public class ShadowRoom { public readonly object Lock = new(); public string Channel; // host bookkeeping public string HostUid; public string HostPid; public string CreatorPid; public long HostSeen; public bool Hosting; public bool Stalled; // timeline public string State = "paused"; public double Position; public long AtServerTime; public double Speed = 1.0; // metadata public string RoomName; public string Url; public string Title; public string Poster; public string Source; public string Type; public int Tmdb; /// Room code as announced by the host in the lobby (ad frames); /// lets the server list and re-announce rooms it keeps alive. public string Code; // server-side hold barrier (only while Hosting) public HashSet HoldWaiting; public double HoldPosition; public Timer HoldTimer; public DateTime CreatedUtc = DateTime.UtcNow; public DateTime LastActivityUtc = DateTime.UtcNow; /// Display name of the member that announced the room. public string OwnerName; } /// A room advertisement overheard in the lobby channel. public class LobbyAd { public string SenderUid; public long Seen; public string Id; public string Name; public string Title; public string Poster; public string Owner; public int Members; public int Tmdb; public string Type; public bool Pwd; } /// /// The shadow host: the WatchTogether module acting as a state-keeping /// lparty client. While a real host is alive it silently listens and keeps /// a snapshot of the room (url, metadata, timeline). The moment the host's /// connection drops, the server steps in as the host on the wire - the /// members see one "host changed" toast and playback continues without a /// gap. When the original creator returns, the role is handed back. /// public static class ShadowHost { public const string ServerUid = "lampac"; const string LobbyChannel = "lparty-lobby-v1"; const string RoomPrefix = "lparty-r-"; const int MetronomeMs = 2000; const int GcEveryMs = 60000; const double MaxAdvancableSeconds = 3600; const int HoldMaxMs = 15000; const long ReconnectGraceMs = 60000; const int AdTtlMs = 120000; static Timer metronomeTimer; static Timer gcTimer; static int initialized; static readonly ConcurrentDictionary rooms = new(); /// Room advertisements overheard in the lobby channel: the /// host announces its code publicly, so the server can keep listing /// rooms (including ones it keeps alive) without knowing passwords. static readonly ConcurrentDictionary lobbyAds = new(); /// pid -> last hello, per channel; used to skip the join hold /// for members that are reconnecting rather than new. static readonly ConcurrentDictionary> knownPids = new(); /// The wire channel name for a room code. With an empty /// password this is computable server-side, which lets the server link /// a lobby advertisement to the room it belongs to. static string RoomChannelHash(string roomId, string password) { if (string.IsNullOrEmpty(roomId)) return null; string input = roomId.ToUpperInvariant() + "|" + (password ?? string.Empty); using var sha = SHA256.Create(); byte[] hashBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); return RoomPrefix + Convert.ToHexString(hashBytes).ToLowerInvariant()[..24]; } // ---------------------------------------------------------------- lifecycle public static void Start() { if (Interlocked.Exchange(ref initialized, 1) == 1) return; metronomeTimer = new Timer(Metronome, null, MetronomeMs, MetronomeMs); gcTimer = new Timer(Gc, null, GcEveryMs, GcEveryMs); } public static void Stop() { if (Interlocked.Exchange(ref initialized, 0) == 0) return; metronomeTimer?.Dispose(); gcTimer?.Dispose(); metronomeTimer = gcTimer = null; rooms.Clear(); knownPids.Clear(); } // ---------------------------------------------------------------- relay hooks /// Whether the server's virtual member must exist in this /// channel right now (it is the acting host). public static bool ShouldHostChannel(string channelName) { if (channelName == LobbyChannel) return false; return rooms.TryGetValue(channelName, out var room) && room.Hosting; } public static void OnMemberJoined(string channelName, RelayChannel channel, RelayMember member) { if (channelName == LobbyChannel) return; if (!rooms.TryGetValue(channelName, out var room)) return; room.LastActivityUtc = DateTime.UtcNow; RelayServer.EnsureServerMember(channel); // Server-hosted room: answer the joiner like a host would. if (room.Hosting) { var pids = knownPids.GetOrAdd(channelName, _ => new ConcurrentDictionary()); AnswerHelloAsHost(room, channel, member, pids, 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); } } public static void OnMemberLeft(string channelName, RelayChannel channel, RelayMember member, bool tornDown) { if (channelName == LobbyChannel) { // A lobby agent disconnected: its ads die with it. foreach (var key in lobbyAds.Keys.ToList()) if (lobbyAds.TryGetValue(key, out var ad) && string.Equals(ad.SenderUid, member.Uid, StringComparison.Ordinal)) lobbyAds.TryRemove(key, out var dead); return; } if (!rooms.TryGetValue(channelName, out var room)) return; if (!room.Hosting && !string.IsNullOrEmpty(room.HostUid) && string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal)) BecomeHost(room, channel, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); } public static void OnMessage(string channelName, RelayChannel channel, RelayMember member, JsonElement msg, long now) { if (channelName == LobbyChannel) { HandleLobby(member, msg, now); return; } var room = rooms.GetOrAdd(channelName, _ => new ShadowRoom { Channel = channelName }); room.LastActivityUtc = DateTime.UtcNow; string t = GetString(msg, "t"); string pid = GetString(msg, "u"); string name = GetString(msg, "n"); if (!string.IsNullOrEmpty(pid)) member.Pid = pid; if (!string.IsNullOrEmpty(name)) member.Name = name; switch (t) { case "state": ObserveState(room, channel, member, msg, now); break; case "sync": ObserveSync(room, member, msg, now); break; case "act": ObserveAct(room, msg, now); break; case "url": ObserveUrl(room, member, msg); break; case "buf": ObserveBuf(room, member, msg); break; case "host": HandleHostClaim(room, channel, member, msg, now); break; case "hello": HandleHello(room, channel, member, msg, now); break; case "ready": HandleReady(room, channel, member, now); break; case "bye": HandleBye(room, channel, member, now); break; } } /// The lobby is server-answered: hosts advertise their code /// in `ad` frames, the server caches them and re-announces rooms it /// keeps alive - so the list survives a host handover. static void HandleLobby(RelayMember member, JsonElement msg, long now) { string t = GetString(msg, "t"); if (t == "ad") { if (msg.TryGetProperty("r", out var r) && r.ValueKind == JsonValueKind.Object) { string id = GetString(r, "id"); if (!string.IsNullOrWhiteSpace(id) && id.Length <= 16) { lobbyAds[id] = new LobbyAd { SenderUid = member.Uid, Seen = now, Id = id, Name = GetString(r, "name") ?? id, Title = GetString(r, "title") ?? string.Empty, Poster = GetString(r, "poster") ?? string.Empty, Owner = GetString(r, "owner") ?? string.Empty, Members = GetInt(r, "members"), Tmdb = GetInt(r, "tmdb"), Type = GetString(r, "type") ?? "movie", Pwd = GetInt(r, "pwd") != 0, }; } } return; } if (t != "who") return; var listed = new HashSet(StringComparer.Ordinal); // Server-kept rooms first, with live member counts. foreach (var room in rooms.Values) { string code, name, title, poster, owner, type; int tmdb; bool hasUrl; lock (room.Lock) { code = room.Code; hasUrl = !string.IsNullOrEmpty(room.Url); name = room.RoomName; title = room.Title; poster = room.Poster; owner = room.OwnerName; type = room.Type; tmdb = room.Tmdb; } if (string.IsNullOrWhiteSpace(code) || listed.Contains(code) || !hasUrl) continue; var channel = RelayServer.TryGetChannel(room.Channel); int members = channel == null ? 0 : RelayServer.HumanUids(channel).Count; SendLobbyAd(member, now, code, name, title, poster, owner, members, true, tmdb, type); listed.Add(code); } // Cached ads from hosts. Provably alive (passwordless) rooms // outlive the TTL; the rest go stale with their announcer. foreach (var ad in lobbyAds.Values.OrderByDescending(a => a.Seen)) { if (listed.Contains(ad.Id)) continue; string hash = RoomChannelHash(ad.Id, string.Empty); bool tracked = hash != null && rooms.ContainsKey(hash); if (now - ad.Seen > AdTtlMs && !tracked) continue; int members = ad.Members; if (tracked) { var channel = RelayServer.TryGetChannel(hash); if (channel != null) members = RelayServer.HumanUids(channel).Count; } SendLobbyAd(member, now, ad.Id, ad.Name, ad.Title, ad.Poster, ad.Owner, members, true, ad.Tmdb, ad.Type); listed.Add(ad.Id); } } static void SendLobbyAd(RelayMember target, long now, string id, string name, string title, string poster, string owner, int members, bool pwd, int tmdb, string type) { var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "ad", r = new { id, name, title, poster, owner, members, pwd = pwd ? 1 : 0, tmdb, type } }); _ = RelayServer.SendAsync(target, frame); } // ---------------------------------------------------------------- observation static void ObserveState(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) { // While hosting, only the creator may take the room back. if (room.Hosting) { if (!string.Equals(member.Pid, room.CreatorPid, StringComparison.Ordinal)) { AssertHost(room, channel, now); return; } Handback(room, channel, member, now); } string url = GetString(msg, "url"); if (string.IsNullOrWhiteSpace(url)) return; lock (room.Lock) { room.HostUid = member.Uid; room.HostPid = GetString(msg, "own") ?? member.Pid ?? member.Uid; room.CreatorPid = room.HostPid; room.HostSeen = now; room.Hosting = false; room.Stalled = false; room.RoomName = GetString(msg, "rn") ?? room.RoomName; room.Url = url.Trim(); room.Title = GetString(msg, "ti") ?? room.Title; room.Poster = GetString(msg, "po") ?? room.Poster; room.Tmdb = GetInt(msg, "tm"); room.Source = GetString(msg, "src") ?? room.Source; room.Type = GetString(msg, "ty") ?? room.Type; room.Code = GetString(msg, "cd") ?? room.Code; room.OwnerName = member.Alias ?? room.OwnerName; room.State = GetString(msg, "s") == "playing" ? "playing" : "paused"; double p = GetDouble(msg, "p"); if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; room.Speed = GetSpeedOr(msg, room.Speed); room.AtServerTime = now; } RelayServer.EnsureServerMember(channel); } static void ObserveSync(ShadowRoom room, RelayMember member, JsonElement msg, long now) { string s = GetString(msg, "s") == "playing" ? "playing" : "paused"; double p = GetDouble(msg, "p"); double sp = GetSpeedOr(msg, room.Speed); lock (room.Lock) { if (room.Hosting) return; bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); if (!fromHost && string.IsNullOrEmpty(room.HostUid)) { // No known host yet: adopt the sender. room.HostUid = member.Uid; room.HostPid = member.Pid; room.CreatorPid = member.Pid; fromHost = true; } if (!fromHost) return; room.State = s; if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; room.Speed = sp; room.AtServerTime = now; room.HostSeen = now; } } static void ObserveAct(ShadowRoom room, JsonElement msg, long now) { // Acts from anyone move the room; the host's next sync re-asserts. if (room.Hosting) return; string s = GetString(msg, "s") == "playing" ? "playing" : "paused"; double p = GetDouble(msg, "p"); lock (room.Lock) { room.State = s; if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; room.Speed = GetSpeedOr(msg, room.Speed); room.AtServerTime = now; } } static void ObserveUrl(ShadowRoom room, RelayMember member, JsonElement msg) { lock (room.Lock) { bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); if (!fromHost || room.Hosting) return; string url = GetString(msg, "url"); if (string.IsNullOrWhiteSpace(url)) return; room.Url = url.Trim(); string ti = GetString(msg, "ti"); if (!string.IsNullOrEmpty(ti)) room.Title = ti.Trim(); room.State = "paused"; room.Position = 0; room.AtServerTime = 0; room.Stalled = false; } } static void ObserveBuf(ShadowRoom room, RelayMember member, JsonElement msg) { lock (room.Lock) { bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); if (!fromHost) return; room.Stalled = GetBool(msg, "v"); } } static void HandleHostClaim(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) { if (room.Hosting) { // Claims are dropped; the server re-asserts itself. AssertHost(room, channel, now); return; } string claimer = GetString(msg, "u"); if (string.IsNullOrEmpty(claimer)) return; lock (room.Lock) { room.HostUid = member.Uid; room.HostPid = claimer; room.CreatorPid = claimer; room.HostSeen = now; room.Hosting = false; } } static void HandleHello(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) { string pid = GetString(msg, "u") ?? member.Pid; string name = GetString(msg, "n"); string cd = GetString(msg, "cd"); var pids = knownPids.GetOrAdd(channel.Name, _ => new ConcurrentDictionary()); long seenBefore = 0; if (!string.IsNullOrEmpty(pid)) { pids.TryGetValue(pid, out seenBefore); pids[pid] = now; } // The first participant stating the code links the room to its listing. if (!string.IsNullOrWhiteSpace(cd)) { lock (room.Lock) { if (string.IsNullOrEmpty(room.Code)) room.Code = cd.Trim().ToUpperInvariant(); } } if (!room.Hosting) return; if (!string.IsNullOrEmpty(pid) && string.Equals(pid, room.CreatorPid, StringComparison.Ordinal)) { Handback(room, channel, member, now); return; } AnswerHelloAsHost(room, channel, member, pids, seenBefore, now); } /// Server-hosting room, newcomer arrived: introduce the room /// and, like a real host, freeze it while they buffer. static void AnswerHelloAsHost(ShadowRoom room, RelayChannel channel, RelayMember member, ConcurrentDictionary pids, long seenBefore, long now) { if (!room.Hosting) return; RelayServer.EnsureServerMember(channel); SendStateFrame(room, member); bool reconnected = now - seenBefore < ReconnectGraceMs; bool playing; lock (room.Lock) playing = room.State == "playing" && !string.IsNullOrEmpty(room.Url); if (!playing) return; if (reconnected) return; StartHold(room, channel, now); } static void HandleReady(ShadowRoom room, RelayChannel channel, RelayMember member, long now) { lock (room.Lock) { if (!room.Hosting || room.HoldWaiting == null) return; room.HoldWaiting.Remove(member.Uid); if (room.HoldWaiting.Count > 0) return; } ReleaseHold(room, channel, now); } static void HandleBye(ShadowRoom room, RelayChannel channel, RelayMember member, long now) { bool wasHost; lock (room.Lock) wasHost = !room.Hosting && !string.IsNullOrEmpty(room.HostUid) && string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal); if (wasHost) BecomeHost(room, channel, now); } // ---------------------------------------------------------------- host transitions static void BecomeHost(ShadowRoom room, RelayChannel channel, long now) { if (room.Hosting) return; lock (room.Lock) { room.Hosting = true; room.HostUid = ServerUid; room.HostSeen = now; room.Stalled = false; if (room.State == "playing" && room.AtServerTime > 0) { // Continue exactly where the dead host was last heard. double elapsed = (now - room.AtServerTime) / 1000.0; if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) room.Position += elapsed * room.Speed; room.AtServerTime = now; } } RelayServer.EnsureServerMember(channel); AssertHost(room, channel, now); double pos; string state; double speed; lock (room.Lock) { state = room.State; pos = room.Position; speed = room.Speed; } var sync = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "sync", u = ServerUid, s = state, p = pos, sp = speed }); RelayServer.Broadcast(channel, null, sync); } static void Handback(ShadowRoom room, RelayChannel channel, RelayMember member, long now) { lock (room.Lock) { room.Hosting = false; room.HostUid = member.Uid; room.HostPid = member.Pid; room.HostSeen = now; room.Stalled = false; } var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "host", u = member.Pid, n = member.Name ?? member.Alias ?? string.Empty }); RelayServer.Broadcast(channel, null, frame); } static void AssertHost(ShadowRoom room, RelayChannel channel, long now) { var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "host", u = ServerUid, n = "Lampac" }); RelayServer.Broadcast(channel, null, frame); } // ---------------------------------------------------------------- hold barrier (server as host) static void StartHold(ShadowRoom room, RelayChannel channel, long now) { lock (room.Lock) { if (room.HoldWaiting != null) return; // already holding AdvanceClock(room, now); room.HoldPosition = room.Position; room.HoldWaiting = new HashSet(RelayServer.HumanUids(channel), StringComparer.Ordinal); if (room.HoldWaiting.Count == 0) return; } var holdFrame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "hold", u = ServerUid, p = room.HoldPosition, sp = room.Speed }); RelayServer.Broadcast(channel, null, holdFrame); lock (room.Lock) { room.HoldTimer?.Dispose(); room.HoldTimer = new Timer(_ => { var ch = RelayServer.TryGetChannel(room.Channel); if (ch != null) ReleaseHold(room, ch, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); }, null, HoldMaxMs, Timeout.Infinite); } } /// Freeze the clock base at the current position. static void AdvanceClock(ShadowRoom room, long now) { if (room.State == "playing" && room.AtServerTime > 0) { double elapsed = (now - room.AtServerTime) / 1000.0; if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) room.Position += elapsed * room.Speed; } room.AtServerTime = now; } static void ReleaseHold(ShadowRoom room, RelayChannel channel, long now) { double position; lock (room.Lock) { room.HoldWaiting = null; room.HoldTimer?.Dispose(); room.HoldTimer = null; room.State = "playing"; room.Position = room.HoldPosition; room.AtServerTime = now; position = room.HoldPosition; } var goFrame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "go", u = ServerUid, p = position, sp = room.Speed }); RelayServer.Broadcast(channel, null, goFrame); } // ---------------------------------------------------------------- metronome /// While the server hosts, nobody's player drives the clock: /// the metronome advances the room by wall time and beats every couple /// of seconds, exactly like the host heartbeats it replaces. static void Metronome(object _) { try { long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); foreach (var room in rooms.Values) { if (!room.Hosting) continue; var channel = RelayServer.TryGetChannel(room.Channel); if (channel == null) continue; bool playing; lock (room.Lock) { playing = room.State == "playing" && !room.Stalled; if (playing && room.AtServerTime > 0) { double elapsed = (now - room.AtServerTime) / 1000.0; if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) room.Position += elapsed * room.Speed; room.AtServerTime = now; } } if (!playing) continue; double pos, speed; string state; lock (room.Lock) { pos = room.Position; speed = room.Speed; state = room.State; } var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "sync", u = ServerUid, s = state, p = pos, sp = speed }); RelayServer.Broadcast(channel, null, frame); } } catch { } } // ---------------------------------------------------------------- gc static void Gc(object _) { try { var emptyLimit = DateTime.UtcNow.AddMinutes(-Math.Max(5, ModInit.conf.gc_empty_timeout_minutes)); var lifeLimit = DateTime.UtcNow.AddHours(-Math.Max(1, ModInit.conf.gc_max_lifetime_hours)); foreach (var room in rooms.Values.ToArray()) { bool hasHumans = false; var channel = RelayServer.TryGetChannel(room.Channel); if (channel != null) { lock (channel.Lock) hasHumans = channel.Members.Keys.Any(u => !string.Equals(u, ServerUid, StringComparison.Ordinal)); } bool expired = room.CreatedUtc < lifeLimit || (!hasHumans && room.LastActivityUtc < emptyLimit); if (expired) { rooms.TryRemove(room.Channel, out var deadRoom); knownPids.TryRemove(room.Channel, out var deadPids); } } long cutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ReconnectGraceMs; foreach (var pids in knownPids.Values) { foreach (var key in pids.Keys.ToList()) { if (!pids.TryGetValue(key, out var seen) || seen < cutoff) pids.TryRemove(key, out var deadPid); } } long adCutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - AdTtlMs; foreach (var key in lobbyAds.Keys.ToList()) { if (!lobbyAds.TryGetValue(key, out var ad) || ad.Seen < adCutoff) lobbyAds.TryRemove(key, out var deadAd); } } catch { } } // ---------------------------------------------------------------- helpers static void SendStateFrame(ShadowRoom room, RelayMember target) { long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); double pos; string state; double speed; lock (room.Lock) { pos = room.Position; speed = room.Speed; state = room.State; if (state == "playing" && room.AtServerTime > 0) { double elapsed = (now - room.AtServerTime) / 1000.0; if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) pos += elapsed * room.Speed; } } var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "state", rn = room.RoomName ?? string.Empty, own = ServerUid, url = room.Url ?? string.Empty, ti = room.Title ?? string.Empty, po = room.Poster ?? string.Empty, tm = room.Tmdb, src = room.Source ?? string.Empty, ty = room.Type ?? "movie", s = state, p = pos, sp = speed, }); _ = RelayServer.SendAsync(target, frame); } static string GetString(JsonElement obj, string name) { if (obj.ValueKind != JsonValueKind.Object) return null; if (!obj.TryGetProperty(name, out var el)) return null; if (el.ValueKind == JsonValueKind.String) return el.GetString(); if (el.ValueKind == JsonValueKind.Null) return null; return el.ToString(); } static double GetDouble(JsonElement obj, string name) { if (obj.ValueKind != JsonValueKind.Object) return 0; if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetDouble(out var val)) return val; return 0; } static double GetSpeedOr(JsonElement obj, double fallback) { double v = GetDouble(obj, "sp"); return v >= 0.25 && v <= 4.0 ? v : fallback; } static int GetInt(JsonElement obj, string name) { if (obj.ValueKind != JsonValueKind.Object) return 0; if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var val)) return val; return 0; } static bool GetBool(JsonElement obj, string name) { if (obj.ValueKind != JsonValueKind.Object) return false; return obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.True; } } }