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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user