Полный конвейер записи встреч в 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.mp4https://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 не приводят к потере данных.
Отзывы по теме
Всё прошло хорошо, исполнитель быстро реагировал на вопросы и помог решить проблему. Спасибо!
visupSTUDIO · Настройка vps, настройка сервера
16.12.2025 · ⭐ 5/5
Всё прошло хорошо, исполнитель быстро реагировал на вопросы и помог решить проблему. Спасибо!
Все сделали оперативно. Будем и дальше обращаться. Рекомендую!
rotant · Настройка vps, настройка сервера
10.12.2025 · ⭐ 5/5
Все сделали оперативно. Будем и дальше обращаться. Рекомендую!
Все сделали оперативно. Михаил всегда на связи. Будем и дальше обращаться
samstiray · Настройка vps, настройка сервера
10.12.2025 · ⭐ 5/5
Все сделали оперативно. Михаил всегда на связи. Будем и дальше обращаться
Михаил, профессионал! Уже ни первый раз показал это на практике.
Vadim_U · Настройка vps, настройка сервера
Освоившийся покупатель03.12.2025 · ⭐ 5/5
Михаил, профессионал! Уже ни первый раз показал это на практике.
Огромное спасибо Михаилу, обратился к нему с очень срочным вопросом по настройке сервера, так как сам в этом не очень силен а нужно сайт показать заказчику. Ответ быстрый, помощь без лишних слов и очень быстро! Желаю вам много заказов и лучшего рейтинга! Спасибо огромное!
Ekleo · Настройка vps, настройка сервера
Очень мощный покупатель28.11.2025 · ⭐ 5/5
Огромное спасибо Михаилу, обратился к нему с очень срочным вопросом по настройке сервера, так как сам в этом не очень силен а нужно сайт показать заказчику. Ответ быстрый, помощь без лишних слов и очень быстро! Желаю вам много заказов и лучшего рейтинга! Спасибо огромное!
Спасибо, быстро помог.
Bodanov · Настройка vps, настройка сервера
Мощный покупатель28.11.2025 · ⭐ 5/5
Спасибо, быстро помог.