Bandera: Русский Русский Bandera: English English

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.mp4
    • https://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.

¿Necesitas ayuda?

Escríbeme y te ayudaré a resolver el problema

Publicaciones relacionadas