Флаг: English English

Полный конвейер записи встреч в Jitsi Meet: от Jibri до Notion и HLS через Caddy

Опубликовано 25.12.2025

Jitsi Meet из коробки отлично решает задачу видеоконференций. Связка Jitsi Meet + Jibri позволяет записывать встречи — и на этом многие установки останавливаются.

Но как только Jitsi начинает использоваться не эпизодически, а в рабочем процессе, очень быстро появляются вопросы:

  • Где централизованно хранить записи?
  • Как автоматически публиковать ссылки для команды?
  • Как избавиться от тяжёлых MP4 и перейти к потоковому просмотру?
  • Как раздавать записи по HTTPS, не раскрывая структуру каталогов?
  • Как сделать всё это автоматически, без ручного участия администратора?

Ниже — полноценный продакшн-конвейер с кодом: от финализации записи Jibri до публикации в Notion и асинхронного транскодинга MP4→HLS с раздачей через Caddy.


Начальная архитектура (baseline)

Компоненты

  • Jitsi Meet — конференции.
  • Jibri — запись (захват аудио/видео и сохранение на диск).
  • Recordings FS — файловая система с записями.
  • Notion DB — каталог встреч и ссылок.
  • ffmpeg воркер — транскодирование в HLS.
  • Caddy — HTTPS-выдача статики (MP4/HLS), BasicAuth, без листинга.

Базовая структура файлов

recordings/
└── <room-id>/
    └── meeting.mp4

Ключевая идея конвейера

Jibri делает только запись MP4. Всё остальное — внешняя автоматика: finalize → публикация → асинхронный HLS → обновление Notion → уборка.

Это упрощает поддержку и даёт идемпотентность: любой шаг можно повторять безопасно.


Notion как каталог встреч

Схема базы (минимум)

Создайте в Notion database со свойствами:

  • Name (title)
  • Date (date)
  • Recording URL (url)
  • (опционально) Status (select: recorded/processing/published/error)
  • (опционально) Room (rich text)
  • (опционально) Provider (select: mp4/hls)

Дальше будем писать туда запись через Notion API.


Переменные окружения и секреты

Чтобы не хардкодить ничего в скриптах, используем 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 (не рекомендую),
# you can do it by setting:
# PUBLIC_URL_AUTH="user:pass@"
PUBLIC_URL_AUTH=""

# Concurrency / locking
LOCK_DIR="/var/lock/jitsi-recording-pipeline"

Создайте директории:

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: публикация MP4 в Notion сразу после записи

Что делает finalize

  • Находит MP4 в директории записи.
  • Формирует публичную ссылку на MP4 (через Caddy).
  • Создаёт страницу/строку в Notion DB.
  • Записывает туда ссылку (MP4), дату, room-id.
  • (опционально) ставит статус recorded.

Код finalize.sh

Файл: /usr/local/bin/jitsi-finalize.sh

#!/usr/bin/env bash
set -euo pipefail

# Jibri обычно передаёт путь к директории с записью.
# Мы делаем скрипт максимально терпимым: принимаем либо директорию, либо файл.
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 (берём самый большой как "главный", на случай если файлов несколько)
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
# Если PUBLIC_URL_AUTH пустой — просто https://host/...
PUBLIC_URL="${PUBLIC_BASE_URL}/${ROOM_ID}/${MP4_BASENAME}"
if [[ -n "${PUBLIC_URL_AUTH}" ]]; then
  # вставляем user:pass@ после https://
  PUBLIC_URL="$(echo "$PUBLIC_URL" | sed -E "s#^https://#https://${PUBLIC_URL_AUTH}#")"
fi

# Meeting title: можно улучшить, если вытаскиваете название из метаданных Jitsi/JSON
MEETING_TITLE="${NOTION_NAME_PREFIX} ${ROOM_ID}"

# Date: используем mtime mp4 как дату встречи (практичный дефолт)
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
# Требования: 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"

Права:

sudo chmod +x /usr/local/bin/jitsi-finalize.sh

Подключение finalize к Jibri (общий принцип)

В зависимости от пакета/дистрибутива Jitsi/Jibri точки интеграции отличаются, но идея одна:

  • Jibri по завершении записи вызывает ваш скрипт и передаёт путь к директории.

Если у вас уже есть finalize.sh от Jitsi, типовой паттерн такой:

  • оставляете штатную финализацию (если нужна),
  • добавляете ваш хук.

Пример «обвязки» (концептуально):

# somewhere in jibri finalize pipeline
/usr/local/bin/jitsi-finalize.sh "/recordings/<room-id>"

2) Раздача файлов через Caddy: HTTPS, BasicAuth, без листинга

Требования

  • Прямые ссылки должны работать:

    • https://rec.example.com/<room-id>/meeting.mp4
    • https://rec.example.com/<room-id>/v0/master.m3u8
  • Листинг директорий запрещён:

    • https://rec.example.com/ не должен показывать дерево.
  • Доступ защищён BasicAuth.

Caddyfile (пример)

/etc/caddy/Caddyfile:

rec.example.com {

  # Корень с записями (смонтирован/доступен как /recordings)
  root * /recordings

  encode zstd gzip

  # Важно: не показываем листинг директорий
  file_server {
    browse off
  }

  # BasicAuth (caddy hash-password --algorithm bcrypt)
  basicauth /* {
    admin $2a$12$REPLACE_WITH_BCRYPT_HASH
  }

  # Более корректные заголовки для HLS
  @hls {
    path *.m3u8 *.ts
  }
  header @hls Content-Type application/octet-stream

  # Безопасные заголовки (минимум)
  header {
    X-Content-Type-Options "nosniff"
    Referrer-Policy "no-referrer"
  }

  # Ограничим методы (не обязательно, но приятно)
  @notGet {
    not method GET HEAD
  }
  respond @notGet 405
}

Генерация bcrypt-хэша:

caddy hash-password --algorithm bcrypt --plaintext 'S3curePassw0rd'

3) HLS воркер: ffmpeg → HLS → update Notion → delete MP4

Почему отдельный воркер

Транскодирование CPU-heavy и может занимать долго. Поэтому:

  • finalize публикует MP4 сразу.
  • воркер раз в N минут «догоняет» и переводит в HLS.
  • после успешного обновления Notion MP4 можно удалить.

3.1) Вспомогательная утилита: обновление страницы Notion

Сделаем маленькую функцию в bash-скрипте, чтобы не копировать curl.


3.2) Код hls-code.sh

Файл: /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 обычно вернёт объект страницы. Если вернулся error — там будет "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"

  # Один профиль (пример: 480p). Можно расширить до ABR ниже.
  # Важно: используем HLS fMP4 или TS? Здесь — TS сегменты (шире поддержка).
  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 как точка входа (даже если один профиль)
  cat > "${outdir}/master.m3u8" <<'EOF'
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=854x480
stream.m3u8
EOF
}

# Проходим по директориям вида recordings/<room-id>
# Комната = директория первого уровня
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"

  # Если нет notion_id — не трогаем: finalize ещё не отработал или запись "не наша"
  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"

  # Если HLS уже есть — просто гарантируем ссылку и убираем mp4 (если он ещё остался)
  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

  # Если mp4 нет — ничего не делаем
  if [[ -z "$mp4_file" ]]; then
    continue
  fi

  log "Process room=$room_id mp4=$(basename "$mp4_file")"

  # Ставим статус processing (опционально)
  notion_update "$page_id" "${PUBLIC_BASE_URL}/${room_id}/$(basename "$mp4_file")" "mp4" "processing" || true

  # Транскодирование
  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

    # Обновляем Notion. Только после успеха — удаляем 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"
      # HLS оставляем: повторный запуск просто обновит Notion и удалит mp4 позже
    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."

Права:

sudo chmod +x /usr/local/bin/jitsi-hls-worker.sh

4) Cron: запуск каждые 20 минут, но не с 10:00 до 18:00 по Москве

Мы уже упоминали этот сценарий: сервер живёт в UTC (пример Thu Dec 25 03:41:02 UTC 2025), а окно нужно по Москве.

Вариант A (рекомендованный): CRON_TZ

Если ваша cron-реализация поддерживает CRON_TZ (в большинстве современных cron — да), можно сделать так, чтобы расписание считалось в московском часовом поясе, независимо от timezone сервера.

/etc/cron.d/jitsi-hls-worker:

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

# Расписание считаем по Москве
CRON_TZ=Europe/Moscow

# Каждые 20 минут, но только вне интервала 10:00-17:59 (т.е. разрешено 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

Вариант B: через systemd timer (если хочется «по-взрослому»)

Если вы любите строгую эксплуатацию, можно перевести воркер на systemd timer и контролировать логи через journalctl. Но вы просили cron — оставляем cron как основной вариант.


5) Логи cron и диагностика

Вывод мы уже перенаправляем в файл:

  • /var/log/jitsi-recording-pipeline/hls-cron.log
  • /var/log/jitsi-recording-pipeline/hls-worker.log
  • /var/log/jitsi-recording-pipeline/finalize.log

Посмотреть «живые» логи:

tail -f /var/log/jitsi-recording-pipeline/hls-cron.log

Если нужно проверить, срабатывает ли cron вообще:

  • Debian/Ubuntu часто пишут в:

    • /var/log/syslog (по CRON)
  • RHEL/CentOS — в:

    • /var/log/cron

Примеры:

grep CRON /var/log/syslog | tail -n 50
# или
tail -n 50 /var/log/cron

6) Улучшения поверх базовой схемы (с кодом)

6.1) Защита от «частично созданного HLS»

Если ffmpeg упал посередине, в v0/ могут остаться сегменты. Хорошая практика:

  • писать в временную папку v0.tmp,
  • после успеха атомарно переименовывать в v0.

Пример (в make_hls):

tmp="${outdir}.tmp"
rm -rf "$tmp"
mkdir -p "$tmp"

# генерируем в tmp
# ...
# после успеха:
rm -rf "$outdir"
mv "$tmp" "$outdir"

6.2) Адаптивный HLS (ABR) — несколько профилей

Если захотите «как у взрослых» (360p/480p/720p), ffmpeg можно запускать с несколькими потоками. Концептуальный пример (упрощённый):

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 при этом всё так же хранит одну ссылку: на master.m3u8.

6.3) Разделение «архив» и «публикация»

После публикации HLS можно уносить MP4 в S3/MinIO (дешёвый архив), а на диске оставлять только HLS. Это делается отдельной задачей и не ломает текущую схему.


Итоговая архитектура

[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 вне 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

Заключение

Смысл этого конвейера в том, что вы превращаете «запись встречи на сервере» в продуктовый, эксплуатационно устойчивый процесс:

  • MP4 появляется сразу и доступен по ссылке (минимальная задержка для команды),
  • тяжёлое транскодирование уходит в фон,
  • Notion становится каталогом и “панелью”,
  • Caddy обеспечивает безопасную выдачу без лишних сервисов,
  • идемпотентность гарантирует, что сбои Notion/ffmpeg не приводят к потере данных.

Отзывы по теме

Огромное спасибо Михаилу, обратился к нему с очень срочным вопросом по настройке сервера, так как сам в этом не очень силен а нужно сайт показать заказчику. Ответ быстрый, помощь без лишних слов и очень быстро! Желаю вам много заказов и лучшего рейтинга! Спасибо огромное!

Ekleo

Ekleo · Настройка vps, настройка сервера

Очень мощный покупатель

28.11.2025 · ⭐ 5/5

Огромное спасибо Михаилу, обратился к нему с очень срочным вопросом по настройке сервера, так как сам в этом не очень силен а нужно сайт показать заказчику. Ответ быстрый, помощь без лишних слов и очень быстро! Желаю вам много заказов и лучшего рейтинга! Спасибо огромное!

Нужна помощь?

Свяжись со мной и я помогу решить проблему

Похожие посты