Files
crm-calls/calls/backend/Calls.Api/Recording/JitsiClients.cs
crm-calls agent e49f5daabf Запись: startService Jibri ждёт до 60 с; запись, которую Jibri уже пишет для этого звонка (каталог /recordings/<id>), подхватывается, даже если startService не ответил
Случай 2026-10-15 (звонок c058d2a24157f99bc4f19): startService ответил позже 5 с, Jibri звонок записал,
а монитор закрыл его как skipped — запись осталась на диске без обработки.
2026-10-15 09:03:49 +00:00

100 lines
5.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace Calls.Api.Recording;
/// <summary>Участники комнаты из prosody (mod_muc_size: GET /room?room=…&amp;domain=…), сеть crm-calls-bridge.</summary>
public sealed class ProsodyRooms(IHttpClientFactory http, RecordingOptions opt)
{
/// <summary>Имена участников (без focus и без скрытого рекордера — у них пустой ник). null — prosody недоступен.</summary>
public async Task<List<string>?> ParticipantsAsync(string room, CancellationToken ct)
{
try
{
using var req = new HttpRequestMessage(HttpMethod.Get,
$"{opt.ProsodyUrl}/room?room={Uri.EscapeDataString(room)}&domain={Uri.EscapeDataString(opt.XmppDomain)}");
// Маршрут mod_muc_size висит на виртуальном хосте XMPP-домена.
req.Headers.Host = opt.XmppDomain;
using var r = await http.CreateClient("jitsi").SendAsync(req, ct);
if (r.StatusCode == HttpStatusCode.NotFound) return []; // комнаты нет — пусто
if (!r.IsSuccessStatusCode) return null;
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
var names = new List<string>();
foreach (var o in doc.RootElement.EnumerateArray())
{
var name = o.TryGetProperty("display_name", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString()!.Trim() : "";
if (name.Length > 0) names.Add(name);
}
return names;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return null; }
}
}
/// <summary>
/// Jibri HTTP API (http://jitsi-jibri:2222/jibri/api/v1.0). Запись в файл под скрытым пользователем recorder,
/// каталог записи — RECORDINGS_DIR/&lt;sessionId&gt;.
/// </summary>
public sealed class JibriClient(IHttpClientFactory http, RecordingOptions opt)
{
public enum Busy { Idle, Busy, Unknown }
/// <summary>startService отвечает только после запуска Chrome (под нагрузкой — дольше 5 с), stopService — после закрытия ffmpeg.</summary>
public static readonly TimeSpan ServiceTimeout = TimeSpan.FromSeconds(60);
/// <summary>
/// Jibri принял запись этой сессии: он создаёт каталог RECORDINGS_DIR/&lt;sessionId&gt; (общий каталог хоста).
/// Так видно, что Jibri пишет именно этот звонок, даже если ответ на startService не дошёл.
/// </summary>
public bool HasSession(string sessionId) => Directory.Exists(Path.Combine(opt.RecordingsDir, sessionId));
public async Task<Busy> StatusAsync(CancellationToken ct)
{
try
{
using var r = await http.CreateClient("jitsi").GetAsync($"{opt.JibriUrl}/jibri/api/v1.0/health", ct);
if (!r.IsSuccessStatusCode) return Busy.Unknown;
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
var s = doc.RootElement.GetProperty("status").GetProperty("busyStatus").GetString();
return s == "IDLE" ? Busy.Idle : s == "BUSY" ? Busy.Busy : Busy.Unknown;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException or KeyNotFoundException or InvalidOperationException)
{ return Busy.Unknown; }
}
/// <summary>true — Jibri принял запрос (дальше он асинхронно заходит в комнату).</summary>
public async Task<(bool Ok, string Detail)> StartAsync(string sessionId, string room, CancellationToken ct)
{
var body = new
{
sessionId,
callParams = new { callUrlInfo = new { baseUrl = opt.MeetBaseUrl, callName = room } },
callLoginParams = new { domain = opt.RecorderDomain, username = opt.RecorderUser, password = opt.RecorderPassword },
sinkType = "file",
};
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(ServiceTimeout);
using var r = await http.CreateClient("jibri").PostAsJsonAsync($"{opt.JibriUrl}/jibri/api/v1.0/startService", body, cts.Token);
return (r.IsSuccessStatusCode, $"{(int)r.StatusCode} {await r.Content.ReadAsStringAsync(ct)}".Trim());
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return (false, e.Message); }
}
public async Task<bool> StopAsync(CancellationToken ct)
{
try
{
// stopService ждёт, пока Jibri закроет ffmpeg и Chrome — это дольше обычного таймаута клиента.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(ServiceTimeout);
using var r = await http.CreateClient("jibri").PostAsync($"{opt.JibriUrl}/jibri/api/v1.0/stopService", null, cts.Token);
return r.IsSuccessStatusCode;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return false; }
}
}