diff --git a/backend/src/main/java/com/softspm/webinar/media/RecordingView.java b/backend/src/main/java/com/softspm/webinar/media/RecordingView.java index 9e086a4..fb3bdc9 100644 --- a/backend/src/main/java/com/softspm/webinar/media/RecordingView.java +++ b/backend/src/main/java/com/softspm/webinar/media/RecordingView.java @@ -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()); } } diff --git a/backend/src/main/java/com/softspm/webinar/repository/RecordingRepository.java b/backend/src/main/java/com/softspm/webinar/repository/RecordingRepository.java index 13e5cf7..c9af7ba 100644 --- a/backend/src/main/java/com/softspm/webinar/repository/RecordingRepository.java +++ b/backend/src/main/java/com/softspm/webinar/repository/RecordingRepository.java @@ -25,6 +25,14 @@ public interface RecordingRepository extends JpaRepository { List 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 findByStatus(RecordingStatus status); + /** * Nagrania gotowe do pokazania uczestnikowi. * diff --git a/backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java b/backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java index b1ebcec..e1fb0ee 100644 --- a/backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java +++ b/backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java @@ -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 pending = recordingRepository.findByStatus(RecordingStatus.DOWNLOADING); + if (pending.isEmpty()) { + return; + } + + Map 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()), diff --git a/backend/src/main/java/com/softspm/webinar/video/RecordingScheduler.java b/backend/src/main/java/com/softspm/webinar/video/RecordingScheduler.java new file mode 100644 index 0000000..1e3b4ec --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/video/RecordingScheduler.java @@ -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()); + } + } +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 14da9a8..d3ea97c 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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 } diff --git a/frontend/src/pages/webinars/SpeakQueue.tsx b/frontend/src/pages/webinars/SpeakQueue.tsx index 9db7dd9..7fce8c0 100644 --- a/frontend/src/pages/webinars/SpeakQueue.tsx +++ b/frontend/src/pages/webinars/SpeakQueue.tsx @@ -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(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({ + + {/* + 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. + */} +