using RelayServer.Models.Rtc; using SurrealDb.Net; namespace RelayServer.Services.Rtc; public sealed class RtcCallService { private readonly SurrealDbClient _db; public RtcCallService(SurrealDbClient db) { _db = db; } /// /// Checks whether the specified channel currently has an active RTC call. /// /// The channel to inspect. /// /// True if the channel has an active call; otherwise, false. /// public async Task HasActiveCallAsync(string channelId) { var activeCalls = await _db.Select("rtc_active_calls"); return activeCalls.Any(x => x.ChannelId == channelId && x.IsActive); } /// /// Joins a user to a channel call and determines whether they should become the offerer /// or join an already active call. /// /// The channel being joined. /// The user joining the call. /// /// A join response describing whether a call already exists, who the offer user is, /// and whether the caller should act as the offerer. /// public async Task JoinCallAsync(string channelId, string username) { //TODO: move active call creation logic to WriteOfferAsync Function var activeCalls = await _db.Select("rtc_active_calls"); var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive); if (activeCall is null) { await _db.Create("rtc_active_calls", new RtcActiveCall { ChannelId = channelId, OfferUser = username, IsActive = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }); return new RtcJoinResponse { ChannelId = channelId, HasActiveCall = false, IsOfferer = true, OfferUser = username, OfferSdp = null }; } var offers = await _db.Select("rtc_offers"); var offer = offers //TODO: Remove offer creation in C# .Where(x => x.ChannelId == channelId) .OrderByDescending(x => x.CreatedAt) .FirstOrDefault(); return new RtcJoinResponse { ChannelId = channelId, HasActiveCall = true, IsOfferer = false, OfferUser = activeCall.OfferUser, OfferSdp = offer?.Sdp }; } /// /// Creates or updates the current SDP offer for a user in the specified channel. /// Also refreshes the active call timestamp when a matching active call exists. /// /// The channel the offer belongs to. /// The user creating the offer. /// The SDP offer payload. public async Task WriteOfferAsync(string channelId, string username, string sdp) { var offers = await _db.Select("rtc_offers"); var existing = offers.FirstOrDefault(x => x.ChannelId == channelId && x.Username == username); if (existing is null) { await _db.Create("rtc_offers", new RtcOffer { ChannelId = channelId, Username = username, Sdp = sdp, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }); } else { existing.Sdp = sdp; existing.UpdatedAt = DateTime.UtcNow; await _db.Merge(existing); } var activeCalls = await _db.Select("rtc_active_calls"); var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive); if (activeCall is not null) { activeCall.UpdatedAt = DateTime.UtcNow; await _db.Merge(activeCall); } } /// /// Gets the most recent SDP offer stored for the specified channel. /// /// The channel whose offer should be retrieved. /// /// The latest offer for the channel, or null if no offer exists. /// public async Task GetOfferAsync(string channelId) { var offers = await _db.Select("rtc_offers"); return offers .Where(x => x.ChannelId == channelId) .OrderByDescending(x => x.CreatedAt) .FirstOrDefault(); } /// /// Writes a new SDP answer for the specified channel and refreshes the active call timestamp /// when a matching active call exists. /// /// The channel the answer belongs to. /// The original offer owner. /// The user submitting the answer. /// The SDP answer payload. public async Task WriteAnswerAsync(string channelId, string offerUser, string answerUser, string sdp) { await _db.Create("rtc_answers", new RtcAnswer { ChannelId = channelId, OfferUser = offerUser, AnswerUser = answerUser, Sdp = sdp, CreatedAt = DateTime.UtcNow }); var activeCalls = await _db.Select("rtc_active_calls"); var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive); if (activeCall is not null) { activeCall.UpdatedAt = DateTime.UtcNow; await _db.Merge(activeCall); } } /// /// Gets all answers stored for the specified channel in creation order. /// /// The channel whose answers should be retrieved. /// /// A list of answers for the channel ordered from oldest to newest. /// public async Task> GetAnswersAsync(string channelId) { var answers = await _db.Select("rtc_answers"); return answers .Where(x => x.ChannelId == channelId) .OrderBy(x => x.CreatedAt) .ToList(); } /// /// Gets the most recent answer stored for the specified channel. /// /// The channel whose latest answer should be retrieved. /// /// The newest answer for the channel, or null if no answer exists. /// public async Task GetLatestAnswerAsync(string channelId) { var answers = await _db.Select("rtc_answers"); return answers .Where(x => x.ChannelId == channelId) .OrderByDescending(x => x.CreatedAt) .FirstOrDefault(); } /// /// Writes a new ICE candidate entry for the specified channel and user. /// /// The channel the ICE candidate belongs to. /// The user who produced the ICE candidate. /// The ICE candidate string. /// The SDP media identifier for the candidate, if any. /// The SDP media line index for the candidate, if any. /// /// The signaling direction the candidate belongs to, such as offer or answer. /// public async Task WriteIceCandidateAsync( string channelId, string username, string candidate, string? sdpMid, int? sdpMLineIndex, string direction) { await _db.Create("rtc_ice_candidates", new RtcIceCandidate { ChannelId = channelId, Username = username, Candidate = candidate, SdpMid = sdpMid, SdpMLineIndex = sdpMLineIndex, Direction = direction, CreatedAt = DateTime.UtcNow }); } /// /// Gets all ICE candidates stored for the specified channel in creation order. /// /// The channel whose ICE candidates should be retrieved. /// /// A list of ICE candidates for the channel ordered from oldest to newest. /// public async Task> GetIceCandidatesAsync(string channelId) { var candidates = await _db.Select("rtc_ice_candidates"); return candidates .Where(x => x.ChannelId == channelId) .OrderBy(x => x.CreatedAt) .ToList(); } /// /// Gets ICE candidates for the specified channel that were created by other users /// and match the requested signaling direction. /// /// The channel whose ICE candidates should be retrieved. /// The user to exclude from the results. /// The signaling direction to match. /// /// A list of matching ICE candidates ordered from oldest to newest. /// public async Task> GetIceCandidatesForOthersAsync(string channelId, string username, string direction) { var candidates = await _db.Select("rtc_ice_candidates"); return candidates .Where(x => x.ChannelId == channelId && x.Username != username && x.Direction == direction) .OrderBy(x => x.CreatedAt) .ToList(); } /// /// Leaves the active call for the specified channel. In the current implementation, /// the call is only marked inactive when the offer user leaves. /// /// The channel whose call should be left. /// The user leaving the call. public async Task LeaveCallAsync(string channelId, string username) { var activeCalls = await _db.Select("rtc_active_calls"); var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive); if (activeCall is null) return; if (activeCall.OfferUser == username) { activeCall.IsActive = false; activeCall.UpdatedAt = DateTime.UtcNow; await _db.Merge(activeCall); } } public async Task GetOffersAsync() { var offers = await _db.Select("rtc_offers"); return offers; } }