// DevOps
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.
// Reviews
Reseñas relacionadas
Llegué con una solicitud costosa para la configuración de un servidor VPS, pero durante la consulta Mikhail propuso una solución mucho más sencilla y económica. Al final ahorré dinero y tiempo. Mikhail — un verdadero experto que trabaja por el resultado del cliente, no por la factura. ¡Lo recomiendo!
Llegué con una solicitud costosa para la configuración de un servidor VPS, pero durante la consulta Mikhail propuso una solución mucho más simple y económica. Al final ahorré presupuesto y tiempo. Mikhail es un …
Configuración de VPS, configuración del servidor
12.05.2026 · ★ 5/5
¡Excelente trabajo! Configuró el servidor muy rápido, instaló el panel y configuró la IP. ¡Sin duda lo recomiendo!
¡Excelente trabajo! Muy rápido configuró el servidor, instaló el panel y configuró la IP. ¡Sin duda lo recomiendo!
Configuración de VPS, configuración del servidor
19.04.2026 · ★ 5/5
Todo perfecto, ayudó de forma rápida y profesional, gracias, lo recomiendo a la comunidad
Todo perfecto, ayudó de forma rápida y profesional, gracias, lo recomiendo a la comunidad
Configuración de VPS, configuración del servidor
16.04.2026 · ★ 5/5
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.
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 …
Configuración de VPS, configuración del servidor
18.02.2026 · ★ 5/5
Todo se hizo de manera rápida y precisa. Lo recomiendo.
Todo se hizo rápido y con precisión. Lo recomiendo.
Configuración de VPS, configuración del servidor
17.01.2026 · ★ 5/5
Todo salió bien, el profesional respondió rápidamente a las preguntas y ayudó a resolver el problema. ¡Gracias!
Todo fue bien, el profesional respondió rápidamente a las preguntas y ayudó a resolver el problema. ¡Gracias!
Configuración de VPS, configuración del servidor
16.12.2025 · ★ 5/5
// Contact
¿Necesitas ayuda?
Escríbeme y te ayudaré a resolver el problema
// Related