Nagrywanie spotkania: przycisk w panelu i domykanie statusu
Deploy PROD / deploy (push) Successful in 26s

Nagrywania NIE DALO SIE URUCHOMIC. Backend mial `startRecording`
i `stopRecording`, Egress byl skonfigurowany i pisal prosto do MinIO, ale
brakowalo dwoch ogniw: przycisku w panelu i domkniecia stanu po zakonczeniu.
`completeRecording` nie bylo wolane z ZADNEGO miejsca w kodzie.

PRZYCISK W PANELU PROWADZENIA
Obok trybow mikrofonu i wideo, bo tam prowadzacy podejmuje decyzje o sali.
Osobno od nich, bo nagrywanie nie dotyczy uprawnien uczestnikow: to decyzja
organizatora o utrwaleniu szkolenia. Start bez potwierdzenia (nagrywanie
niczego nie ujawnia i da sie je zatrzymac), stop z potwierdzeniem, bo tego
fragmentu nie nagra sie drugi raz.

Stan czytany jest przy wejsciu z listy nagran, nie tylko trzymany lokalnie:
prowadzacy moze odswiezyc strone albo wrocic z innego urzadzenia i musi
zobaczyc, ze nagranie trwa, zamiast zaczynac drugie rownolegle. Sluzy do tego
`providerRecordingId` dopisane do `RecordingView`.

DOMYKANIE PRZEZ ODPYTYWANIE, NIE WEBHOOK
LiveKit podpisuje webhooki tokenem JWT, ktorego `VideoWebhookController` nie
rozpoznaje: przyjmuje wylacznie wspolny sekret w naglowku, tak jak Daily.
Odpytywanie jest przy tym ODPORNIEJSZE: zgloszenie, ktore nie dojdzie (restart
backendu w zlej chwili, awaria sieci), zostawia wpis w stanie DOWNLOADING na
zawsze, a zadanie w tle samo to nadrabia. Opoznienie do minuty nie ma
znaczenia, bo montaz pliku po stronie Egress i tak trwa dluzej.

EGRESS_COMPLETE domyka wpis rozmiarem i czasem trwania, FAILED/ABORTED/
LIMIT_REACHED oznacza go jako nieudany Z POWODEM: wpis zostaje w bazie, zeby
administrator widzial, ze proba byla i czym sie skonczyla. Zadanie nie rusza,
gdy dostawca nie jest LiveKit, i nie oznacza nagrania jako nieudanego, gdy
Egress zwyczajnie nie zna juz zadania: brak informacji to nie to samo co blad.

`reconcileRecordings` jest nietransakcyjne (wola serwer po sieci) i domyka
kazdy wpis przez `@Lazy self`: samowywolanie omija proxy Springa, a wtedy
zapis odbywa sie bez transakcji i stan nagrania nie zmienia sie wcale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 10:55:39 +02:00
parent fc1726b8b7
commit 0421f19630
6 changed files with 300 additions and 1 deletions
@@ -27,6 +27,15 @@ public record RecordingView(
/** Adres do odtwarzania. Null, dopoki nagranie nie jest gotowe. */
String url,
String error,
/**
* Identyfikator zadania u dostawcy.
*
* Panel prowadzenia rozpoznaje po nim TRWAJACE nagranie: bez tego
* prowadzacy po odswiezeniu strony widzialby przycisk "Nagraj
* spotkanie" przy nagrywaniu juz w toku i zaczynalby drugie.
* Widok jest administracyjny, a sam identyfikator niczego nie otwiera.
*/
String providerRecordingId,
OffsetDateTime createdAt) {
public static RecordingView of(Recording recording) {
@@ -41,6 +50,7 @@ public record RecordingView(
recording.getContentType(),
recording.isPlayable() ? "/api/public/recordings/" + recording.getId() : null,
recording.getError(),
recording.getProviderRecordingId(),
recording.getCreatedAt());
}
}
@@ -25,6 +25,14 @@ public interface RecordingRepository extends JpaRepository<Recording, Long> {
List<Recording> findByWebinarIdAndStatusOrderByCreatedAtDesc(Long webinarId, RecordingStatus status);
/**
* Nagrania w danym stanie z CALEJ instancji.
*
* Uzywane przez zadanie domykajace nagrania Egress: pyta o wpisy wciaz
* w stanie DOWNLOADING, bez wiedzy, do ktorego webinaru naleza.
*/
List<Recording> findByStatus(RecordingStatus status);
/**
* Nagrania gotowe do pokazania uczestnikowi.
*
@@ -16,12 +16,16 @@ import livekit.LivekitEgress;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Nagrywanie LiveKit przez komponent Egress.
@@ -60,6 +64,17 @@ public class LiveKitEgressService {
private final String minioSecretKey;
private final String minioBucket;
/**
* Referencja na samego siebie, przez proxy Springa.
*
* `reconcileRecordings` jest NIETRANSAKCYJNE (wola serwer po sieci), wiec
* wywolanie `completeRecording` przez `this` omijaloby proxy i zapis
* odbywalby sie bez transakcji: stan nagrania nie zmienialby sie wcale.
* Ten sam blad zdarzyl sie juz w tym projekcie trzy razy i pilnuje go
* `TransactionalSelfInvocationTest`.
*/
private final LiveKitEgressService self;
public LiveKitEgressService(SettingsService settings,
WebinarRepository webinarRepository,
RecordingRepository recordingRepository,
@@ -67,7 +82,8 @@ public class LiveKitEgressService {
@Value("${app.minio.endpoint}") String minioEndpoint,
@Value("${app.minio.access-key}") String minioAccessKey,
@Value("${app.minio.secret-key}") String minioSecretKey,
@Value("${app.minio.bucket}") String minioBucket) {
@Value("${app.minio.bucket}") String minioBucket,
@Lazy LiveKitEgressService self) {
this.settings = settings;
this.webinarRepository = webinarRepository;
this.recordingRepository = recordingRepository;
@@ -76,6 +92,7 @@ public class LiveKitEgressService {
this.minioAccessKey = minioAccessKey;
this.minioSecretKey = minioSecretKey;
this.minioBucket = minioBucket;
this.self = self;
}
/**
@@ -227,6 +244,111 @@ public class LiveKitEgressService {
}, () -> log.warn("Webhook Egress {} bez pasujacego wpisu w recordings", egressId));
}
/**
* Domyka nagrania, ktore Egress juz skonczyl.
*
* DLACZEGO ODPYTYWANIE, A NIE WEBHOOK
* -----------------------------------------------------------------------
* LiveKit potrafi zglosic koniec nagrania webhookiem, ale podpisuje go
* tokenem JWT, ktorego `VideoWebhookController` nie rozpoznaje: przyjmuje
* wylacznie wspolny sekret w naglowku, tak jak Daily. Doklodanie tam
* drugiego mechanizmu uwierzytelniania to osobna praca.
*
* Odpytywanie jest przy tym ODPORNIEJSZE: webhook, ktory nie dojdzie
* (restart backendu w zlej chwili, chwilowa awaria sieci), zostawia wpis
* w stanie DOWNLOADING na zawsze. Zadanie w tle samo to nadrabia przy
* nastepnym przebiegu.
*
* Opoznienie do minuty nie ma znaczenia: montaz pliku po stronie Egress
* i tak trwa dluzej.
*
* Metoda jest publiczna i **nietransakcyjna**: wolanie sieciowe trwa,
* a kazdy wpis domykany jest osobno przez `completeRecording`, ktore ma
* wlasna transakcje. Trzymanie jednej transakcji przez cale odpytywanie
* blokowaloby polaczenie na czas odpowiedzi serwera.
*/
public void reconcileRecordings() {
List<Recording> pending = recordingRepository.findByStatus(RecordingStatus.DOWNLOADING);
if (pending.isEmpty()) {
return;
}
Map<String, LivekitEgress.EgressInfo> byId;
try {
var response = egressClient().listEgress().execute();
if (!response.isSuccessful() || response.body() == null) {
log.warn("Nie udalo sie pobrac stanu Egress: {}", describe(response.code()));
return;
}
byId = response.body().stream()
.collect(Collectors.toMap(LivekitEgress.EgressInfo::getEgressId,
info -> info, (a, b) -> a));
} catch (Exception e) {
// brak polaczenia nie jest bledem trwalym: sprobujemy za minute
log.warn("Nie udalo sie pobrac stanu Egress: {}", e.getMessage());
return;
}
for (Recording recording : pending) {
String egressId = recording.getProviderRecordingId();
if (egressId == null || egressId.isBlank()) {
// nagranie wgrane recznie, nie ma czego pytac u dostawcy
continue;
}
LivekitEgress.EgressInfo info = byId.get(egressId);
if (info == null) {
/*
* Egress nie zna juz tego zadania. Historia jest u niego
* ograniczona, wiec po dluzszej przerwie wpis po prostu
* znika: zostawiamy go bez zmian, zeby administrator mogl
* zdecydowac sam, zamiast oznaczac nagranie jako nieudane
* na podstawie braku informacji.
*/
continue;
}
switch (info.getStatus()) {
case EGRESS_COMPLETE -> {
long size = info.getFileResultsList().stream()
.mapToLong(LivekitEgress.FileInfo::getSize)
.max()
.orElse(0L);
// Egress podaje czas trwania w nanosekundach
long nanos = info.getFileResultsList().stream()
.mapToLong(LivekitEgress.FileInfo::getDuration)
.max()
.orElse(0L);
// zero oznacza "nie wiem", a nie nagranie zerowej dlugosci
Integer seconds = nanos > 0 ? (int) (nanos / 1_000_000_000L) : null;
self.completeRecording(egressId, size, seconds);
}
case EGRESS_FAILED, EGRESS_ABORTED, EGRESS_LIMIT_REACHED ->
self.failRecording(egressId, info.getStatus().name());
default -> {
// STARTING, ACTIVE, ENDING: nagranie wciaz trwa
}
}
}
}
/**
* Oznacza nagranie jako nieudane.
*
* Wpis ZOSTAJE w bazie, zamiast zniknac: administrator ma zobaczyc, ze
* proba nagrania byla i czym sie skonczyla, zamiast szukac nagrania,
* ktorego nie ma i o ktorym nic nie wiadomo.
*/
@Transactional
public void failRecording(String egressId, String reason) {
recordingRepository.findByProviderRecordingId(egressId).ifPresent(recording -> {
recording.setStatus(RecordingStatus.FAILED);
recording.setError("Egress zakonczyl nagranie ze statusem " + reason);
recordingRepository.save(recording);
log.warn("Nagranie {} nieudane: {}", egressId, reason);
});
}
private EgressServiceClient egressClient() {
return EgressServiceClient.createClient(
httpUrl(apiUrl()),
@@ -0,0 +1,59 @@
package com.softspm.webinar.video;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Domykanie nagran zakonczonych przez Egress.
*
* DLACZEGO ODPYTYWANIE, A NIE WEBHOOK
* ---------------------------------------------------------------------------
* LiveKit zglasza koniec nagrania webhookiem podpisanym tokenem JWT, ktorego
* `VideoWebhookController` nie rozpoznaje: przyjmuje wylacznie wspolny sekret
* w naglowku, tak jak Daily. Bez tego wpis w `recordings` zostawal w stanie
* DOWNLOADING **na zawsze**, a nagranie nie pojawialo sie ani w panelu, ani
* u uczestnikow, chociaz plik lezal juz w MinIO.
*
* Odpytywanie jest przy tym odporniejsze niz webhook: zgloszenie, ktore nie
* dojdzie (restart backendu w zlej chwili, chwilowa awaria sieci), zostawia
* nagranie w limbo bez zadnego sygnalu. Zadanie w tle samo to nadrabia.
*
* Opoznienie do minuty nie ma znaczenia, bo montaz pliku po stronie Egress
* i tak trwa dluzej.
*/
@Component
public class RecordingScheduler {
private static final Logger log = LoggerFactory.getLogger(RecordingScheduler.class);
private final LiveKitEgressService egressService;
private final SettingsService settings;
public RecordingScheduler(LiveKitEgressService egressService, SettingsService settings) {
this.egressService = egressService;
this.settings = settings;
}
@Scheduled(fixedDelayString = "${RECORDING_RECONCILE_INTERVAL_MS:60000}")
public void reconcile() {
/*
* Instancja bez LiveKit nie ma czego odpytywac, a proba polaczenia
* konczylaby sie ostrzezeniem w logu co minute. Sprawdzamy ustawienie
* przy kazdym przebiegu, bo dostawce zmienia sie w panelu, bez restartu.
*/
if (!"LIVEKIT".equalsIgnoreCase(settings.get(SettingKey.VIDEO_PROVIDER))) {
return;
}
try {
egressService.reconcileRecordings();
} catch (Exception e) {
// blad odpytywania nie moze zatrzymac zadania na stale
log.error("Nie udalo sie sprawdzic stanu nagran: {}", e.getMessage());
}
}
}