Nagrywanie spotkania: przycisk w panelu i domykanie statusu
Deploy PROD / deploy (push) Successful in 26s
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user