Files
crm-calls/calls/backend/Calls.Api/Recording/CallMonitor.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

142 lines
7.7 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.
namespace Calls.Api.Recording;
/// <summary>
/// Раз в несколько секунд опрашивает prosody: кто в комнатах. Отсюда:
/// • занятость для лимита звонков (CallGate);
/// • начало звонка (≥ RECORD_MIN_PARTICIPANTS участников) → Jibri начинает запись;
/// • конец звонка (комната пуста дольше EmptyGrace) → Jibri останавливается, запись уходит в обработку;
/// • список участников для страницы в вики.
/// Во время записи пишет в журнал память, своп и загрузку хоста (HostStats).
/// </summary>
public sealed class CallMonitor(
ProsodyRooms prosody, JibriClient jibri, CallGate gate, CallsRepo calls, Store store,
RecordingOptions opt, ILogger<CallMonitor> log) : BackgroundService
{
private readonly HostStats _host = new();
private DateTimeOffset _statsAt = DateTimeOffset.MinValue;
private int _jibriIdleTicks;
private bool _prosodyDown;
protected override async Task ExecuteAsync(CancellationToken ct)
{
log.LogInformation("Монітор дзвінків: prosody {P}, jibri {J}, запис {E}, MAX_CONCURRENT_CALLS={M}, MIN_CALL_SECONDS={S}",
opt.ProsodyUrl, opt.JibriUrl, opt.Enabled ? "увімкнено" : "вимкнено", opt.MaxConcurrentCalls, opt.MinCallSeconds);
log.LogInformation("Jibri: {S}", await jibri.StatusAsync(ct));
while (!ct.IsCancellationRequested)
{
try { await TickAsync(ct); }
catch (Exception e) when (e is not OperationCanceledException) { log.LogError(e, "Монітор дзвінків: помилка"); }
try { await Task.Delay(opt.PollInterval, ct); } catch (OperationCanceledException) { break; }
}
}
public async Task TickAsync(CancellationToken ct)
{
var rooms = new HashSet<string>(store.RecentRooms(TimeSpan.FromHours(13)));
foreach (var c in calls.AllOpen()) rooms.Add(c.Room);
var occupied = new Dictionary<string, int>();
var ok = true;
foreach (var room in rooms)
{
var names = await prosody.ParticipantsAsync(room, ct);
if (names == null) { ok = false; continue; }
if (names.Count > 0) occupied[room] = names.Count;
await TrackAsync(room, names, ct);
}
if (ok) gate.Update(occupied);
if (!ok && !_prosodyDown) log.LogWarning("Prosody недоступний ({P}): зайнятість кімнат невідома", opt.ProsodyUrl);
if (ok && _prosodyDown) log.LogInformation("Prosody знову доступний");
_prosodyDown = !ok;
if (calls.AllOpen().Any(c => c.Status == "recording") && DateTimeOffset.UtcNow - _statsAt > TimeSpan.FromSeconds(15))
{
_statsAt = DateTimeOffset.UtcNow;
log.LogInformation("Ресурси хоста під час запису: {S}", _host.Sample());
}
}
private async Task TrackAsync(string room, List<string> names, CancellationToken ct)
{
var call = calls.Open(room);
if (names.Count > 0)
{
if (call == null)
{
if (names.Count < opt.RecordMinParticipants) return; // один человек ждёт — ещё не звонок
var ws = store.WorkspaceOfRoom(room);
if (ws == null) return;
call = calls.Create(room, ws);
log.LogInformation("Дзвінок {C} почався: кімната {R}, учасників {N}", call.Id, room, names.Count);
}
calls.Seen(call.Id, Merge(call, names));
if (call.Status == "active" && opt.Enabled) await TryStartAsync(call, ct);
else if (call.Status == "recording") await WatchJibriAsync(call, ct);
return;
}
// комната пуста
if (call == null) return;
if (calls.Now - call.LastSeenAt < (long)opt.EmptyGrace.TotalSeconds) return;
await EndAsync(call, ct, "усі вийшли");
}
private async Task TryStartAsync(CallRow call, CancellationToken ct)
{
var session = call.Id;
// Прошлый startService не дождался ответа, но Jibri запрос принял и пишет этот звонок.
if (jibri.HasSession(session)) { Started(call, session, "Jibri вже пише цей дзвінок"); return; }
var st = await jibri.StatusAsync(ct);
if (st != JibriClient.Busy.Idle)
{
log.LogWarning("Дзвінок {C}: Jibri {S}, запис поки не почато", call.Id, st);
return;
}
var (ok, detail) = await jibri.StartAsync(session, call.Room, ct);
if (ok) { Started(call, session, "запис почато"); return; }
if (jibri.HasSession(session)) { Started(call, session, $"Jibri пише цей дзвінок, хоча startService не відповів ({detail})"); return; }
// Каталога ещё может не быть (Jibri запускает Chrome) — следующий тик увидит его и подхватит запись.
log.LogError("Дзвінок {C}: Jibri не почав запис: {D}", call.Id, detail);
}
private void Started(CallRow call, string session, string what)
{
calls.RecordingStarted(call.Id, session);
_jibriIdleTicks = 0;
log.LogInformation("Дзвінок {C}: {W} (Jibri, сесія {S}); ресурси: {H}", call.Id, what, session, _host.Sample());
}
/// <summary>Jibri сам перестал писать (сбой) при живом звонке: закрываем этот кусок, следующий тик начнёт новый.</summary>
private async Task WatchJibriAsync(CallRow call, CancellationToken ct)
{
var st = await jibri.StatusAsync(ct);
_jibriIdleTicks = st == JibriClient.Busy.Idle ? _jibriIdleTicks + 1 : 0;
if (_jibriIdleTicks >= 3)
{
log.LogWarning("Дзвінок {C}: Jibri перестав записувати під час дзвінка — закриваю цей запис, почну новий", call.Id);
await EndAsync(call, ct, "Jibri зупинився");
}
}
private async Task EndAsync(CallRow call, CancellationToken ct, string why)
{
var recording = call.Status == "recording";
if (recording && !await jibri.StopAsync(ct))
log.LogWarning("Дзвінок {C}: Jibri не відповів на stopService (запис зупиниться сам)", call.Id);
calls.Ended(call.Id, call.LastSeenAt, recording);
log.LogInformation("Дзвінок {C} закінчився ({W}): тривалість {D} с, запис: {R}; ресурси: {H}",
call.Id, why, call.LastSeenAt - call.StartedAt, recording ? "є" : "немає", _host.Sample());
}
/// <summary>Все, кто был в звонке; гости — с пометкой (по выданным гостевым пропускам этой комнаты).</summary>
private List<Participant> Merge(CallRow call, List<string> names)
{
var list = new List<Participant>(call.Participants);
var known = new HashSet<string>(list.Select(p => p.Name), StringComparer.Ordinal);
var fresh = names.Where(n => !known.Contains(n)).Distinct().ToList();
if (fresh.Count == 0) return list;
var guests = store.GuestNames(call.Room, call.StartedAt - 13 * 3600);
foreach (var n in fresh) list.Add(new Participant(n, guests.Contains(n)));
return list;
}
}