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
+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">