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());
}
}
}
+2
View File
@@ -322,6 +322,8 @@ export type Recording = {
/** Adres odtwarzania. Null, dopoki nagranie nie jest gotowe. */
url: string | null
error: string | null
/** Identyfikator zadania u dostawcy. Panel rozpoznaje po nim nagranie w toku. */
providerRecordingId: string | null
createdAt: string
}
@@ -56,6 +56,75 @@ export function SpeakQueue({
// okno potwierdzenia wlaczenia otwartego mikrofonu (zamiast window.confirm)
const [confirmOpen, setConfirmOpen] = useState(false)
/*
* Nagrywanie. `null` oznacza "nie nagrywamy", a wartosc to identyfikator
* zadania Egress, ktory trzeba oddac przy zatrzymaniu.
*
* Stan pobierany jest przy wejsciu z listy nagran, a nie tylko trzymany
* lokalnie: prowadzacy moze odswiezyc strone albo wrocic z innego urzadzenia
* i musi zobaczyc, ze nagranie trwa, zamiast zaczynac drugie rownolegle.
*/
const [egressId, setEgressId] = useState<string | null>(null)
const [recordingBusy, setRecordingBusy] = useState(false)
useEffect(() => {
let active = true
void (async () => {
try {
const response = await client.get<{ id: number; status: string; providerRecordingId: string | null }[]>(
`/admin/webinars/${webinarId}/recordings`,
)
if (!active) return
// DOWNLOADING oznacza "plik powstaje", czyli nagranie w toku
const running = response.data.find(
(r) => r.status === 'DOWNLOADING' && r.providerRecordingId,
)
setEgressId(running?.providerRecordingId ?? null)
} catch {
// brak listy nie moze zablokowac panelu prowadzenia
}
})()
return () => {
active = false
}
}, [webinarId])
/**
* Wlaczenie i zatrzymanie nagrywania.
*
* Bez potwierdzenia przy starcie: nagrywanie niczego nie ujawnia uczestnikom
* i da sie je zatrzymac w kazdej chwili. Przy zatrzymaniu pytamy, bo drugi
* raz tego fragmentu sie nie nagra.
*/
async function toggleRecording() {
if (recordingBusy) return
if (egressId && !confirm('Zatrzymać nagrywanie? Tego fragmentu nie nagrasz ponownie.')) {
return
}
setRecordingBusy(true)
try {
if (egressId) {
await client.post(
`/admin/webinars/${webinarId}/recording/stop?egressId=${encodeURIComponent(egressId)}`,
)
setEgressId(null)
toasts.success('Nagrywanie zatrzymane. Plik pojawi się w zakładce Pliki za chwilę.')
} else {
const response = await client.post<{ egressId: string }>(
`/admin/webinars/${webinarId}/recording/start`,
)
setEgressId(response.data.egressId)
toasts.success('Nagrywanie rozpoczęte.')
}
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się zmienić stanu nagrywania'))
} finally {
setRecordingBusy(false)
}
}
/**
* Przelaczenie trybu otwartego mikrofonu.
*
@@ -230,6 +299,35 @@ export function SpeakQueue({
</span>
</span>
</button>
{/*
Nagrywanie. Osobno od trybow mikrofonu i wideo, bo nie dotyczy
uprawnien uczestnikow: to decyzja organizatora o tym, czy szkolenie
ma zostac utrwalone. Zgoda uczestnikow na nagranie jest jego sprawa.
*/}
<button
type="button"
onClick={toggleRecording}
disabled={recordingBusy}
aria-pressed={egressId !== null}
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors duration-fast ease-standard disabled:opacity-50 ${
egressId
? 'border-alarm-500/30 bg-alarm-50 text-alarm-600'
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
<Icon name={recordingBusy ? 'loader' : 'video'} size={16} className="shrink-0" />
<span className="min-w-0 flex-1">
<span className="block font-medium">
{egressId ? 'Nagrywanie trwa' : 'Nagraj spotkanie'}
</span>
<span className="block text-xs opacity-80">
{egressId
? 'Kliknij, żeby zatrzymać. Plik trafi do zakładki Pliki.'
: 'Zapis obrazu i dźwięku na serwerze'}
</span>
</span>
</button>
</header>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">