Случай 2026-10-15 (звонок c058d2a24157f99bc4f19): startService ответил позже 5 с, Jibri звонок записал, а монитор закрыл его как skipped — запись осталась на диске без обработки.
142 lines
7.7 KiB
C#
142 lines
7.7 KiB
C#
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;
|
||
}
|
||
}
|