using Livekit.Server.Sdk.Dotnet; using Microsoft.Extensions.Options; namespace Nuuru.Server.Services { public interface ILiveKitRoomAdminClient { bool IsEnabled { get; } Task> ListParticipantsAsync(string roomName, CancellationToken cancellationToken = default); Task SetParticipantDeafenedAsync(string roomName, string participantIdentity, bool deafened, CancellationToken cancellationToken = default); Task RemoveParticipantAsync(string roomName, string participantIdentity, CancellationToken cancellationToken = default); } public sealed record LiveKitRoomParticipant(string Identity, string Name, bool? IsMicrophoneEnabled, bool? IsDeafened); public class LiveKitRoomAdminClient : ILiveKitRoomAdminClient { private readonly ILogger _logger; private readonly RoomServiceClient? _client; public LiveKitRoomAdminClient(IOptions options, ILogger logger) { _logger = logger; var value = options.Value; if (!value.IsConfigured) { return; } var apiUrl = value.GetEffectiveApiUrl(); if (string.IsNullOrWhiteSpace(apiUrl)) { _logger.LogWarning("LiveKit is configured without a usable API URL."); return; } _client = new RoomServiceClient(apiUrl, value.ApiKey!, value.ApiSecret!); } public bool IsEnabled => _client != null; public async Task> ListParticipantsAsync(string roomName, CancellationToken cancellationToken = default) { if (_client == null || string.IsNullOrWhiteSpace(roomName)) { return []; } try { var response = await _client.ListParticipants(new ListParticipantsRequest { Room = roomName }); return response.Participants .Select(participant => { var attributes = TryGetAttributes(participant); var isDeafened = TryGetBooleanAttribute(attributes, "deafened"); var isMicrophoneEnabled = GetMicrophoneEnabled(participant); return new LiveKitRoomParticipant( participant.Identity, participant.Name, isMicrophoneEnabled, isDeafened); }) .ToList(); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to list LiveKit participants for room {RoomName}", roomName); return []; } } public async Task SetParticipantDeafenedAsync(string roomName, string participantIdentity, bool deafened, CancellationToken cancellationToken = default) { if (_client == null || string.IsNullOrWhiteSpace(roomName) || string.IsNullOrWhiteSpace(participantIdentity)) { return false; } try { var participant = await _client.GetParticipant(new RoomParticipantIdentity { Room = roomName, Identity = participantIdentity }); var updateParticipantRequest = new UpdateParticipantRequest { Room = roomName, Identity = participantIdentity }; updateParticipantRequest.Attributes.Add("deafened", deafened ? "true" : "false"); if (participant.Permission != null) { updateParticipantRequest.Permission = ClonePermissionWithSubscription(participant.Permission, !deafened); } await _client.UpdateParticipant(updateParticipantRequest); var participants = await _client.ListParticipants(new ListParticipantsRequest { Room = roomName }); var remoteAudioTrackSids = participants.Participants .Where(currentParticipant => !string.Equals(currentParticipant.Identity, participantIdentity, StringComparison.Ordinal)) .SelectMany(currentParticipant => currentParticipant.Tracks) .Where(IsAudioTrack) .Select(track => track.Sid) .Where(trackSid => !string.IsNullOrWhiteSpace(trackSid)) .Distinct(StringComparer.Ordinal) .ToList(); if (remoteAudioTrackSids.Count > 0) { var updateSubscriptionsRequest = new UpdateSubscriptionsRequest { Room = roomName, Identity = participantIdentity, Subscribe = !deafened }; foreach (var trackSid in remoteAudioTrackSids) { updateSubscriptionsRequest.TrackSids.Add(trackSid); } await _client.UpdateSubscriptions(updateSubscriptionsRequest); } return true; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to update deafened state for participant {ParticipantIdentity} in room {RoomName}", participantIdentity, roomName); return false; } } public async Task RemoveParticipantAsync(string roomName, string participantIdentity, CancellationToken cancellationToken = default) { if (_client == null || string.IsNullOrWhiteSpace(roomName) || string.IsNullOrWhiteSpace(participantIdentity)) { return; } try { await _client.RemoveParticipant(new RoomParticipantIdentity { Room = roomName, Identity = participantIdentity }); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to remove participant {ParticipantIdentity} from LiveKit room {RoomName}", participantIdentity, roomName); } } private static ParticipantPermission ClonePermissionWithSubscription(ParticipantPermission permission, bool canSubscribe) { var clonedPermission = new ParticipantPermission { CanSubscribe = canSubscribe, CanPublish = permission.CanPublish, CanPublishData = permission.CanPublishData, Hidden = permission.Hidden, Recorder = permission.Recorder, CanUpdateMetadata = permission.CanUpdateMetadata, Agent = permission.Agent, CanSubscribeMetrics = permission.CanSubscribeMetrics, CanManageAgentSession = permission.CanManageAgentSession }; foreach (var source in permission.CanPublishSources) { clonedPermission.CanPublishSources.Add(source); } return clonedPermission; } private static bool? GetMicrophoneEnabled(ParticipantInfo participant) { var audioTracks = participant.Tracks .Where(IsAudioTrack) .ToList(); if (audioTracks.Count == 0) { return null; } return audioTracks.Any(track => !track.Muted); } private static bool IsAudioTrack(TrackInfo track) { return string.Equals(track.Type.ToString(), "Audio", StringComparison.OrdinalIgnoreCase); } private static IReadOnlyDictionary? TryGetAttributes(object participant) { var property = participant.GetType().GetProperty("Attributes"); if (property?.GetValue(participant) is IReadOnlyDictionary attributes) { return attributes; } if (property?.GetValue(participant) is IDictionary mutableAttributes) { return new Dictionary(mutableAttributes); } return null; } private static bool? TryGetBooleanAttribute(IReadOnlyDictionary? attributes, string key) { if (attributes == null || !attributes.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) { return null; } return bool.TryParse(value, out var parsedValue) ? parsedValue : null; } } }