Flujo completo de grabación de reuniones en Jitsi Meet: de Jibri a Notion y HLS mediante Caddy
Publicado el 25.12.2025
Jitsi Meet funciona de serie y resuelve muy bien la tarea de videoconferencias. La combinación Jitsi Meet + Jibri permite grabar las reuniones — y en muchas instalaciones ahí se quedan.
Pero tan pronto como Jitsi se usa no de forma esporádica, sino en el flujo de trabajo, muy pronto surgen preguntas:
- ¿Dónde almacenar las grabaciones de forma centralizada?
- ¿Cómo publicar automáticamente los enlaces para el equipo?
- ¿Cómo librarse de los pesados MP4 y pasar a reproducción por streaming?
- ¿Cómo servir las grabaciones por HTTPS sin revelar la estructura de directorios?
- ¿Cómo hacer todo esto automáticamente, sin intervención manual del administrador?
A continuación — una canalización de producción completa con código: desde la finalización de la grabación de Jibri hasta la publicación en Notion y el transcodificado asíncrono MP4→HLS con entrega a través de Caddy.
Arquitectura inicial (baseline)
Componentes
- Jitsi Meet — conferencias.
- Jibri — grabación (captura de audio/video y guardado en disco).
- Recordings FS — sistema de ficheros con las grabaciones.
- Notion DB — catálogo de reuniones y enlaces.
- Trabajador ffmpeg — transcodificación a HLS.
- Caddy — entrega HTTPS de estáticos (MP4/HLS), BasicAuth, sin listado.
Estructura de ficheros básica
recordings/
└── <room-id>/
└── meeting.mp4
Idea clave de la canalización
Jibri solo hace la grabación MP4. Todo lo demás — automatización externa: finalize → publicación → HLS asíncrono → actualización en Notion → limpieza.
Esto simplifica el mantenimiento y aporta idempotencia: cualquier paso se puede repetir de forma segura.
Notion como catálogo de reuniones
Esquema de la base (mínimo)
Crea en Notion una database con propiedades:
- Name (title)
- Date (date)
- Recording URL (url)
- (opcional) Status (select: recorded/processing/published/error)
- (opcional) Room (rich text)
- (opcional) Provider (select: mp4/hls)
Más adelante escribiremos la entrada mediante la API de Notion.
Variables de entorno y secretos
Para no hardcodear nada en los scripts, usamos un archivo env.
/etc/jitsi/recording-pipeline.env:
# Notion
NOTION_TOKEN="secret_xxx" # internal integration token
NOTION_DATABASE_ID="xxxxxxxxxxxxxxxxxxxx" # database id
# Public base URL where Caddy serves recordings
PUBLIC_BASE_URL="https://rec.example.com"
# Where recordings are stored on disk
RECORDINGS_ROOT="/recordings"
# Optional: set a static tag/prefix
NOTION_NAME_PREFIX="[Jitsi]"
# Logging
LOG_DIR="/var/log/jitsi-recording-pipeline"
# BasicAuth is handled by Caddy. If you still want to embed user:pass in URL (no lo recomiendo),
# you can do it by setting:
# PUBLIC_URL_AUTH="user:pass@"
PUBLIC_URL_AUTH=""
# Concurrency / locking
LOCK_DIR="/var/lock/jitsi-recording-pipeline"
Crea los directorios:
sudo mkdir -p /var/log/jitsi-recording-pipeline /var/lock/jitsi-recording-pipeline
sudo chmod 750 /var/log/jitsi-recording-pipeline /var/lock/jitsi-recording-pipeline
1) finalize.sh: publicación del MP4 en Notion justo después de la grabación
Qué hace finalize
- Encuentra el MP4 en el directorio de la grabación.
- Forma un enlace público al MP4 (vía Caddy).
- Crea una página/fila en la DB de Notion.
- Escribe allí el enlace (MP4), la fecha, room-id.
- (opcional) pone el estado
recorded.
Código finalize.sh
Archivo: /usr/local/bin/jitsi-finalize.sh
#!/usr/bin/env bash
set -euo pipefail
# Jibri normalmente pasa la ruta al directorio con la grabación.
# Hacemos el script lo más tolerante posible: aceptamos directorio o archivo.
INPUT_PATH="${1:-}"
if [[ -z "${INPUT_PATH}" ]]; then
echo "Usage: $0 <recording_dir_or_file>" >&2
exit 1
fi
# Load env
ENV_FILE="/etc/jitsi/recording-pipeline.env"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
else
echo "Env file not found: $ENV_FILE" >&2
exit 1
fi
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/finalize.log"
log() { printf '%s %s\n' "$(date -Is)" "$*" | tee -a "$LOG_FILE" >&2; }
# Resolve directory
REC_DIR="$INPUT_PATH"
if [[ -f "$INPUT_PATH" ]]; then
REC_DIR="$(dirname "$INPUT_PATH")"
fi
if [[ ! -d "$REC_DIR" ]]; then
log "ERROR: recording dir not found: $REC_DIR"
exit 1
fi
# Determine room id from path (last component)
ROOM_ID="$(basename "$REC_DIR")"
# Find mp4 (tomamos el más grande como "principal", por si hay varios archivos)
MP4_FILE="$(find "$REC_DIR" -maxdepth 1 -type f -name '*.mp4' -printf '%s\t%p\n' 2>/dev/null | sort -nr | head -n1 | cut -f2- || true)"
if [[ -z "$MP4_FILE" ]]; then
log "ERROR: mp4 not found in $REC_DIR"
exit 1
fi
MP4_BASENAME="$(basename "$MP4_FILE")"
# Build public URL
# Si PUBLIC_URL_AUTH está vacío — simplemente https://host/...
PUBLIC_URL="${PUBLIC_BASE_URL}/${ROOM_ID}/${MP4_BASENAME}"
if [[ -n "${PUBLIC_URL_AUTH}" ]]; then
# insertamos user:pass@ después de https://
PUBLIC_URL="$(echo "$PUBLIC_URL" | sed -E "s#^https://#https://${PUBLIC_URL_AUTH}#")"
fi
# Meeting title: se puede mejorar si extraes el nombre de los metadatos Jitsi/JSON
MEETING_TITLE="${NOTION_NAME_PREFIX} ${ROOM_ID}"
# Date: usamos mtime del mp4 como fecha de la reunión (valor por defecto práctico)
MEETING_DATE="$(date -u -r "$MP4_FILE" +"%Y-%m-%dT%H:%M:%SZ")"
log "Finalize room=$ROOM_ID file=$MP4_BASENAME url=$PUBLIC_URL date=$MEETING_DATE"
# Create page in Notion DB
# Requisitos: curl + jq
if ! command -v jq >/dev/null 2>&1; then
log "ERROR: jq not installed"
exit 1
fi
PAYLOAD="$(jq -n \
--arg db "$NOTION_DATABASE_ID" \
--arg title "$MEETING_TITLE" \
--arg date "$MEETING_DATE" \
--arg url "$PUBLIC_URL" \
--arg room "$ROOM_ID" \
'{
"parent": { "database_id": $db },
"properties": {
"Name": { "title": [ { "text": { "content": $title } } ] },
"Date": { "date": { "start": $date } },
"Recording URL": { "url": $url },
"Room": { "rich_text": [ { "text": { "content": $room } } ] },
"Status": { "select": { "name": "recorded" } },
"Provider": { "select": { "name": "mp4" } }
}
}'
)"
RESP="$(curl -sS -X POST "https://api.notion.com/v1/pages" \
-H "Authorization: Bearer ${NOTION_TOKEN}" \
-H "Content-Type: application/json" \
-H "Notion-Version: 2022-06-28" \
--data "$PAYLOAD"
)"
PAGE_ID="$(echo "$RESP" | jq -r '.id // empty')"
if [[ -z "$PAGE_ID" ]]; then
log "ERROR: Notion create page failed: $(echo "$RESP" | jq -c '.')"
exit 1
fi
# Store page id near recording to enable later update without search
echo "$PAGE_ID" > "${REC_DIR}/.notion_id"
log "OK: notion page created id=$PAGE_ID stored at ${REC_DIR}/.notion_id"
Permisos:
sudo chmod +x /usr/local/bin/jitsi-finalize.sh
Conectar finalize con Jibri (principio general)
Según el paquete/distribución Jitsi/Jibri los puntos de integración difieren, pero la idea es la misma:
- Jibri al finalizar la grabación llama a tu script y le pasa la ruta al directorio.
Si ya tienes un finalize.sh de Jitsi, el patrón típico es:
- dejar la finalización estándar (si es necesaria),
- añadir tu hook.
Ejemplo de “envoltura” (conceptual):
# somewhere in jibri finalize pipeline
/usr/local/bin/jitsi-finalize.sh "/recordings/<room-id>"
2) Entrega de ficheros mediante Caddy: HTTPS, BasicAuth, sin listado
Requisitos
Los enlaces directos deben funcionar:
https://rec.example.com/<room-id>/meeting.mp4https://rec.example.com/<room-id>/v0/master.m3u8
El listado de directorios debe estar prohibido:
https://rec.example.com/no debe mostrar el árbol.
El acceso debe estar protegido con BasicAuth.
Caddyfile (ejemplo)
/etc/caddy/Caddyfile:
rec.example.com {
# Raíz con las grabaciones (montada/disponible como /recordings)
root * /recordings
encode zstd gzip
# Importante: no mostrar listado de directorios
file_server {
browse off
}
# BasicAuth (caddy hash-password --algorithm bcrypt)
basicauth /* {
admin $2a$12$REPLACE_WITH_BCRYPT_HASH
}
# Encabezados más correctos para HLS
@hls {
path *.m3u8 *.ts
}
header @hls Content-Type application/octet-stream
# Encabezados de seguridad (mínimo)
header {
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
}
# Limitar métodos (no obligatorio, pero agradable)
@notGet {
not method GET HEAD
}
respond @notGet 405
}
Generación del hash bcrypt:
caddy hash-password --algorithm bcrypt --plaintext 'S3curePassw0rd'
3) Trabajador HLS: ffmpeg → HLS → actualizar Notion → borrar MP4
Por qué un trabajador separado
La transcodificación consume mucha CPU y puede tardar. Por eso:
- finalize publica el MP4 inmediatamente.
- el trabajador de background, cada N minutos, lo procesa a HLS.
- después de actualizar con éxito Notion, se puede borrar el MP4.
3.1) Utilidad auxiliar: actualización de la página Notion
Haremos una pequeña función en el script bash para no repetir curl.
3.2) Código hls-code.sh
Archivo: /usr/local/bin/jitsi-hls-worker.sh
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="/etc/jitsi/recording-pipeline.env"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
else
echo "Env file not found: $ENV_FILE" >&2
exit 1
fi
mkdir -p "$LOG_DIR" "$LOCK_DIR"
LOG_FILE="$LOG_DIR/hls-worker.log"
log() { printf '%s %s\n' "$(date -Is)" "$*" | tee -a "$LOG_FILE" >&2; }
need_bin() {
command -v "$1" >/dev/null 2>&1 || { log "ERROR: missing binary: $1"; exit 1; }
}
need_bin find
need_bin jq
need_bin curl
need_bin ffmpeg
need_bin flock
# prevent parallel runs
LOCK_FILE="$LOCK_DIR/hls-worker.lock"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "Another worker is running, exit."
exit 0
fi
notion_update() {
local page_id="$1"
local url="$2"
local provider="$3"
local status="$4"
local payload
payload="$(jq -n \
--arg url "$url" \
--arg provider "$provider" \
--arg status "$status" \
'{
"properties": {
"Recording URL": { "url": $url },
"Provider": { "select": { "name": $provider } },
"Status": { "select": { "name": $status } }
}
}'
)"
local resp
resp="$(curl -sS -X PATCH "https://api.notion.com/v1/pages/${page_id}" \
-H "Authorization: Bearer ${NOTION_TOKEN}" \
-H "Content-Type: application/json" \
-H "Notion-Version: 2022-06-28" \
--data "$payload"
)"
# Notion normalmente devolverá un objeto de página. Si devuelve error — habrá "object":"error"
local obj
obj="$(echo "$resp" | jq -r '.object // empty')"
if [[ "$obj" == "error" ]]; then
log "ERROR: Notion update failed: $(echo "$resp" | jq -c '.')"
return 1
fi
return 0
}
make_hls() {
local mp4="$1"
local outdir="$2"
mkdir -p "$outdir"
# Un perfil (ejemplo: 480p). Se puede ampliar a ABR más abajo.
# Importante: ¿usar HLS fMP4 o TS? Aquí — segmentos TS (soporte más amplio).
ffmpeg -hide_banner -y \
-i "$mp4" \
-vf "scale=-2:480" \
-c:v h264 -profile:v main -preset veryfast -crf 23 \
-c:a aac -b:a 128k -ac 2 \
-f hls \
-hls_time 6 \
-hls_list_size 0 \
-hls_segment_filename "${outdir}/seg_%06d.ts" \
"${outdir}/stream.m3u8"
# master.m3u8 como punto de entrada (aunque sea un perfil)
cat > "${outdir}/master.m3u8" <<'EOF'
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=854x480
stream.m3u8
EOF
}
# Recorremos directorios del tipo recordings/<room-id>
# Sala = directorio de primer nivel
while IFS= read -r -d '' rec_dir; do
room_id="$(basename "$rec_dir")"
mp4_file="$(find "$rec_dir" -maxdepth 1 -type f -name '*.mp4' -printf '%s\t%p\n' 2>/dev/null | sort -nr | head -n1 | cut -f2- || true)"
notion_id_file="${rec_dir}/.notion_id"
# Si no hay notion_id — no tocar: finalize aún no ha corrido o la grabación "no es nuestra"
if [[ ! -f "$notion_id_file" ]]; then
[[ -n "$mp4_file" ]] && log "Skip room=$room_id: missing .notion_id"
continue
fi
page_id="$(tr -d '\n\r' < "$notion_id_file" || true)"
if [[ -z "$page_id" ]]; then
log "Skip room=$room_id: empty .notion_id"
continue
fi
hls_dir="${rec_dir}/v0"
master="${hls_dir}/master.m3u8"
# Si HLS ya existe — simplemente garantizar la URL y eliminar mp4 (si todavía existe)
if [[ -f "$master" ]]; then
hls_url="${PUBLIC_BASE_URL}/${room_id}/v0/master.m3u8"
if [[ -n "${PUBLIC_URL_AUTH}" ]]; then
hls_url="$(echo "$hls_url" | sed -E "s#^https://#https://${PUBLIC_URL_AUTH}#")"
fi
if notion_update "$page_id" "$hls_url" "hls" "published"; then
if [[ -n "$mp4_file" ]]; then
log "HLS exists. Notion updated. Deleting mp4 room=$room_id file=$(basename "$mp4_file")"
rm -f -- "$mp4_file"
else
log "HLS exists. Notion ok. No mp4 to delete room=$room_id"
fi
else
log "HLS exists but Notion update failed. Keep mp4 room=$room_id"
fi
continue
fi
# Si no hay mp4 — no hacemos nada
if [[ -z "$mp4_file" ]]; then
continue
fi
log "Process room=$room_id mp4=$(basename "$mp4_file")"
# Poner estado processing (opcional)
notion_update "$page_id" "${PUBLIC_BASE_URL}/${room_id}/$(basename "$mp4_file")" "mp4" "processing" || true
# Transcodificación
if make_hls "$mp4_file" "$hls_dir"; then
hls_url="${PUBLIC_BASE_URL}/${room_id}/v0/master.m3u8"
if [[ -n "${PUBLIC_URL_AUTH}" ]]; then
hls_url="$(echo "$hls_url" | sed -E "s#^https://#https://${PUBLIC_URL_AUTH}#")"
fi
# Actualizamos Notion. Solo tras el éxito — borramos el MP4.
if notion_update "$page_id" "$hls_url" "hls" "published"; then
log "Notion updated to HLS. Deleting mp4 room=$room_id"
rm -f -- "$mp4_file"
else
log "ERROR: HLS created but Notion update failed. Keep mp4 room=$room_id"
# Dejar HLS: una ejecución posterior actualizará Notion y borrará el mp4 después
fi
else
log "ERROR: ffmpeg failed room=$room_id"
notion_update "$page_id" "${PUBLIC_BASE_URL}/${room_id}/$(basename "$mp4_file")" "mp4" "error" || true
fi
done < <(find "$RECORDINGS_ROOT" -mindepth 1 -maxdepth 1 -type d -print0)
log "Worker run complete."
Permisos:
sudo chmod +x /usr/local/bin/jitsi-hls-worker.sh
4) Cron: ejecución cada 20 minutos, pero no desde las 10:00 hasta las 18:00 hora de Moscú
Ya hemos mencionado este escenario: el servidor vive en UTC (ejemplo Thu Dec 25 03:41:02 UTC 2025), y la ventana debe ser según Moscú.
Opción A (recomendada): CRON_TZ
Si tu implementación de cron soporta CRON_TZ (en la mayoría de cron modernos — sí), puedes hacer que el horario se evalúe en la zona horaria de Moscú, independientemente del timezone del servidor.
/etc/cron.d/jitsi-hls-worker:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
# Consideramos el horario en Moscú
CRON_TZ=Europe/Moscow
# Cada 20 minutos, pero solo fuera del intervalo 10:00-17:59 (es decir, permitido 18:00-09:59)
*/20 0-9,18-23 * * * root /usr/local/bin/jitsi-hls-worker.sh >> /var/log/jitsi-recording-pipeline/hls-cron.log 2>&1
Opción B: mediante systemd timer (si prefieres “serio”)
Si te gusta una explotación más formal, puedes mover el trabajador a un systemd timer y controlar logs con journalctl. Pero pediste cron — dejamos cron como opción principal.
5) Logs de cron y diagnóstico
Ya redirigimos la salida a fichero:
/var/log/jitsi-recording-pipeline/hls-cron.log/var/log/jitsi-recording-pipeline/hls-worker.log/var/log/jitsi-recording-pipeline/finalize.log
Ver logs “en vivo”:
tail -f /var/log/jitsi-recording-pipeline/hls-cron.log
Si necesitas comprobar si cron se ejecuta:
Debian/Ubuntu suele escribir en:
/var/log/syslog(por CRON)
RHEL/CentOS — en:
/var/log/cron
Ejemplos:
grep CRON /var/log/syslog | tail -n 50
# o
tail -n 50 /var/log/cron
6) Mejoras sobre el esquema básico (con código)
6.1) Protección contra “HLS parcialmente creado”
Si ffmpeg falla a mitad, en v0/ pueden quedar segmentos. Buena práctica:
- escribir en un directorio temporal
v0.tmp, - tras el éxito renombrar atómicamente a
v0.
Ejemplo (en make_hls):
tmp="${outdir}.tmp"
rm -rf "$tmp"
mkdir -p "$tmp"
# generamos en tmp
# ...
# después del éxito:
rm -rf "$outdir"
mv "$tmp" "$outdir"
6.2) HLS adaptativo (ABR) — varios perfiles
Si quieres “como los grandes” (360p/480p/720p), ffmpeg puede ejecutarse con múltiples flujos. Ejemplo conceptual (simplificado):
ffmpeg -i input.mp4 \
-filter_complex \
"[0:v]split=3[v1][v2][v3]; \
[v1]scale=-2:360[v1out]; \
[v2]scale=-2:480[v2out]; \
[v3]scale=-2:720[v3out]" \
-map [v1out] -map 0:a -c:v:0 h264 -b:v:0 800k -c:a:0 aac -b:a:0 96k \
-map [v2out] -map 0:a -c:v:1 h264 -b:v:1 1200k -c:a:1 aac -b:a:1 128k \
-map [v3out] -map 0:a -c:v:2 h264 -b:v:2 2500k -c:a:2 aac -b:a:2 128k \
-f hls \
-hls_time 6 \
-hls_playlist_type vod \
-hls_flags independent_segments \
-master_pl_name master.m3u8 \
-var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" \
-hls_segment_filename "v%v/seg_%06d.ts" \
"v%v/stream.m3u8"
Notion seguirá almacenando un único enlace: al master.m3u8.
6.3) Separación “archivo” y “publicación”
Tras publicar HLS puedes mover el MP4 a S3/MinIO (archivo más barato), y en disco dejar solo el HLS. Eso se hace en una tarea separada y no rompe el esquema actual.
Arquitectura final
[Jitsi Meet]
|
v
[Jibri] --(writes MP4)--> recordings/<room-id>/meeting.mp4
|
+--> jitsi-finalize.sh (on finalize)
|
+--> create Notion row (MP4 URL)
+--> save .notion_id
cron (CRON_TZ=Europe/Moscow, */20 fuera de 10-18)
|
v
jitsi-hls-worker.sh
|
+--> ffmpeg MP4 → HLS (v0/master.m3u8 + segments)
+--> update Notion URL to HLS
+--> delete MP4 only after Notion update success
[Caddy]
|
+--> HTTPS + BasicAuth
+--> static files from /recordings
+--> no directory listing
Conclusión
La idea de esta canalización es convertir “grabación de una reunión en el servidor” en un proceso productivo y operativamente robusto:
- El MP4 aparece de inmediato y está disponible por enlace (latencia mínima para el equipo),
- la transcodificación pesada pasa al background,
- Notion se convierte en catálogo y “panel”,
- Caddy asegura la entrega segura sin servicios extras,
- la idempotencia garantiza que fallos en Notion/ffmpeg no conduzcan a pérdida de datos.
Reseñas relacionadas
Hubo varios problemas, tanto en la parte técnica como en la comprensión general. Mijaíl respondió rápido a la solicitud, ayudó a aclarar las cosas y resolvió los problemas técnicos; por ello, muchas gracias. Estoy satisfecho con el resultado.
abazawolf · Configuración de VPS, configuración del servidor
18.02.2026 · ⭐ 5/5
Hubo varios problemas relacionados tanto con la parte técnica como con la comprensión en general. Mijaíl respondió rápidamente a la solicitud, ayudó a aclarar las cosas y resolvió los problemas técnicos, por lo que le doy las gracias por ello. Estoy satisfecho con el resultado.
Todo se hizo de manera rápida y precisa. Lo recomiendo.
Akelebra · Configuración de VPS, configuración del servidor
17.01.2026 · ⭐ 5/5
Todo se hizo rápido y con precisión. Lo recomiendo.
Todo salió bien, el profesional respondió rápidamente a las preguntas y ayudó a resolver el problema. ¡Gracias!
visupSTUDIO · Configuración de VPS, configuración del servidor
16.12.2025 · ⭐ 5/5
Todo fue bien, el profesional respondió rápidamente a las preguntas y ayudó a resolver el problema. ¡Gracias!
Lo hicieron todo con rapidez. Seguiremos acudiendo. ¡Lo recomiendo!
rotant · Configuración de VPS, configuración del servidor
10.12.2025 · ⭐ 5/5
Todo lo hicieron con rapidez. Seguiremos acudiendo. ¡Lo recomiendo!
Hicieron todo rápidamente. Mijaíl siempre está disponible. Seguiremos recurriendo a él.
samstiray · Configuración de VPS, configuración del servidor
10.12.2025 · ⭐ 5/5
Todo se hizo con rapidez. Михаил siempre está en contacto. Seguiremos recurriendo a él
¡Mijaíl es un profesional! Ya no es la primera vez que lo demuestra en la práctica.
Vadim_U · Configuración de VPS, configuración del servidor
Cliente acostumbrado03.12.2025 · ⭐ 5/5
Михаил, ¡un profesional! Ya lo ha demostrado en la práctica más de una vez.