Compare commits

...
14 Commits
Author SHA1 Message Date
gilfanovrandClaude Opus 5.5 705a7dce99 rpi: убрать номер записи DHCP (сдвинулся после удаления .101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:12:48 +03:00
gilfanovrandClaude Opus 5.5 94bc983401 rpi-media: стартовая страница Homepage, Radarr/Sonarr → Jellyfin connect
- homepage/: сервисы с описанием и живыми виджетами; ключи — в homepage.env на малине
- Jellyfin-виджет через customapi (встроенный не совместим с Jellyfin 12)
- provision.py: шаги homepage и connect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:12:39 +03:00
gilfanovrandClaude Opus 5.5 f70d5c848f rpi-media: RuTracker через FlareSolverr, таймаут прокси 180 с
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:12:39 +03:00
gilfanovrandClaude Opus 5.5 08aa787b95 rpi: медиацентр (Jellyfin, Seerr, qBittorrent, *arr) в Docker; диск роутера и резерв DHCP
- stacks/rpi-media: docker-compose и идемпотентный provision.py
- servers/rpi.md: раздел «Медиацентр», почему NTFS и SFTP на роутере,
  закрыт хвост про резерв адреса
- AGENTS.md: роль малины

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:12:31 +03:00
gilfanovrandClaude Opus 5.5 880ad61253 rpi: удалена неиспользуемая DHCP-привязка 192.168.10.101
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:05:05 +03:00
astra 7843c6e958 rpi: описать разрешённые занятия и возрастные правила Поли 2026-10-11 16:03:55 +03:00
gilfanovrandClaude Opus 5.5 60558dc4b3 rpi: роутер HG8245X6 — скилл router, сторож устройств и трафика, алерты home-network, дашборд home-net
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:59:58 +03:00
astra f9d027ba86 rpi: открыть доступ Поли по Telegram ID 2026-10-11 15:59:17 +03:00
astra 69c02162db rpi: описать постоянную сессию и журнал Поли 2026-10-11 15:54:44 +03:00
astra 6d7dc4c8f1 rpi: описать профиль Поли и ожидание её Telegram ID 2026-10-11 15:51:55 +03:00
astra 0ffcb63b3c rpi: описать исправление кнопок и ответов детской печати 2026-10-11 15:44:13 +03:00
astra 89ca3cea03 rpi: описать детскую печать единым PDF без подтверждений 2026-10-11 15:38:01 +03:00
gilfanovrandClaude Opus 5.5 facc6c1e65 monwatch: сторож мониторинга fr1 на rpi — раз в час health-проверка, тревога в Telegram напрямую
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:36:56 +03:00
astra b881d861f0 rpi: добавить создание PDF с кириллицей для домашней печати 2026-10-11 15:33:38 +03:00
17 changed files with 1201 additions and 8 deletions
+1
View File
@@ -5,3 +5,4 @@ secrets/
alertmanager/telegram_token
**/telegram_token
stacks/ipcam/.env
__pycache__/
+1 -1
View File
@@ -6,7 +6,7 @@
- [`servers/de1.md`](servers/de1.md) — 213.136.74.247. **Выведен** (все сервисы остановлены, метрики удалены): был прокси/VPN-нодой (telemt, AmneziaWG, OpenVPN) плюс gitea, openclaw, боты — всё уехало на fr1 или в бэкапы. ⚠️ Сам файл описания под вывод не переписан.
- [`servers/fr1.md`](servers/fr1.md) — `fr1.ruzzy.dev` (161.97.93.252). Основная нода (Ubuntu 24.04): мониторинг, gitea, AmneziaWG exit для ru1, боты (claude-channels, openclaw), статика `www`/`share`. Держит апекс `ruzzy.dev`.
- [`servers/ru1.md`](servers/ru1.md) — `85.198.109.86` (VDSina, РФ). VPN entry-нода (OpenVPN ×2, AmneziaWG **до fr1** после переключения, WireGuard клиентов), умный роутинг RU/зарубеж; WEB-прокси Telegram (tproxy-server + MTProxy) на `ru.ruzzy.dev:443`, telemt удалён 2026-10-09. ⚠️ Файл описания сверялся до переключения.
- [`servers/rpi.md`](servers/rpi.md) — `rpi.local` (192.168.10.25, дом, за NAT). Raspberry Pi 4: бэкап личного OneDrive на HDD 4 ТБ (rclone, вечный архив версий, еженедельная сверка хэшей); SMART и статус бэкапов пушит в pushgateway fr1 по SSH. Код — `stacks/rpi-backup/`. Агент Astra (Codex) с управлением из Telegram — репо `ruzzy/astra-bot`.
- [`servers/rpi.md`](servers/rpi.md) — `rpi.local` (192.168.10.25, дом, за NAT). Raspberry Pi 4: бэкап личного OneDrive на HDD 4 ТБ (rclone, вечный архив версий, еженедельная сверка хэшей); SMART и статус бэкапов пушит в pushgateway fr1 по SSH. Код — `stacks/rpi-backup/`. Агент Astra (Codex) с управлением из Telegram — репо `ruzzy/astra-bot`. Медиацентр в Docker (Jellyfin, Seerr, qBittorrent, Radarr/Sonarr/Prowlarr/Bazarr, стартовая страница Homepage :3000) — `stacks/rpi-media/`.
## Поддержание описаний в актуальном состоянии
Тронул сервер — обнови `servers/<хост>.md` в том же прогоне. Контракт и
+1
View File
@@ -115,6 +115,7 @@ backend=systemd banaction=ufw
- Дашборд **Network / Traffic** (`network.json`): RX/TX по интерфейсам, пакеты, ошибки/дропы, статус — с фильтром по instance/device (lo/veth/docker скрыты).
- DNS: `mon.ruzzy.dev` → 161.97.93.252.
- **Pushgateway** (с 2026-10-11) — для хостов за NAT, которых не скрейпнуть (домашняя `rpi`, см. `servers/rpi.md`). Слушает только `127.0.0.1:9091`; job `pushgateway` с `honor_labels: true` (иначе job/instance заменились бы на `pushgateway:9091`). Персистентность в томе `monitoring_pushgateway_data` — рестарт не обнуляет метрики. **Как приходят данные:** хост делает `ssh rus@fr1 <job> < metrics.prom`, forced command `~/bin/pushgw-ingest <instance>` делает `PUT /metrics/job/<job>/instance/<instance>`. instance зашит в authorized_keys, а не берётся от клиента — хост не может писать от чужого имени; имя job фильтруется по `[a-z0-9_]`. Наружу порт не открыт, nginx/ufw не трогали.
- В `~/bin/pushgw-ingest` есть режим `health` (с 2026-10-11): `ssh … health` отдаёт key=value о здоровье Prometheus/Alertmanager/pushgateway, свежести пушей хоста и счётчиках доставки в Telegram — только чтение. Им пользуется внешний сторож `monwatch` на rpi.
- ⚠️ Pushgateway хранит последнее значение вечно: если хост умер, метрики не исчезнут, а «застынут». Поэтому свежесть ловит отдельный алерт `PushMetricsStale` по `push_time_seconds`.
- ⚠️ Живой `~/monitoring/docker-compose.yml` расходится с репо (пароль Grafana вписан литералом), поэтому при деплое 2026-10-11 compose **не копировался**, а блок pushgateway вставлен на месте. Бэкапы: `docker-compose.yml.bak-20261011`, `prometheus/prometheus.yml.bak-20261011`.
- Подключён `ru1` (node + cadvisor + openvpn-exporter, напрямую по публичному IP). `de1` выведен — его таргеты удалены. Планируется: экспортёры wireguard/awg и telemt.
+63 -7
View File
@@ -1,14 +1,14 @@
# rpi — домашняя Raspberry Pi 4 (`rpi.local`, 192.168.10.25)
Поднята 2026-10-11. Домашний сервер за NAT: первая задача — бэкап личного
OneDrive на внешний HDD. Мониторится с fr1 через pushgateway (скрейпить
OneDrive на внешний HDD; с того же дня ещё и медиацентр (Jellyfin + *arr, Docker). Мониторится с fr1 через pushgateway (скрейпить
её снаружи нельзя — она за домашним роутером).
## Железо и ОС
- Raspberry Pi 4, SD 64 ГБ (`mmcblk0`), питание штатное — `vcgencmd get_throttled` = `0x0`.
- Raspberry Pi OS Lite 64-bit **Trixie** (Debian 13), образ `2026-10-06-raspios-trixie-arm64-lite`.
- Headless-настройка — **cloud-init**, а не старые `ssh`/`userconf.txt`: в Trixie Pi Imager пишет `user-data`/`network-config` на `bootfs`. Наш `user-data` создал пользователя, ключ, hostname, таймзону MSK.
- Сеть: Ethernet, DHCP. MAC `dc:a6:32:db:b0:f9`, роутер `192.168.10.254`. ⏳ Резерв адреса на роутере — на пользователе (см. хвосты).
- Сеть: Ethernet 1 Гбит/с, DHCP. MAC `dc:a6:32:db:b0:f9`, роутер `192.168.10.254` (Huawei HG8245X6-10, Cyta). Адрес `192.168.10.25` закреплён за MAC статической DHCP-привязкой на роутере (2026-10-11; была без резерва).
## Доступ
- С Mac: `ssh rpi` (алиас → `rpi.local`, user `rus`, ключ `~/.ssh/id_ed25519`). Резолвится через mDNS (avahi в образе по умолчанию).
@@ -18,7 +18,7 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg
| Устройство | Модель | Роль |
|---|---|---|
| `sda` | WD My Passport 4 ТБ (WD40NDZW, s/n WD-WX82D6228YCD) | ext4 `backup` → `/mnt/backup` |
| — | WD Elements 1 ТБ (WD10SDRW) | **не подключён**, ждёт хаба (APFS, не отформатирован) |
| — | WD Elements 1 ТБ (WD10SDRW) | **в USB роутера**, не малины: NTFS/MBR, метка `ROUTER` → `router:` / `/mnt/router` (см. «Вторая копия») |
- fstab по UUID с `nofail,x-systemd.device-timeout=30s` (`stacks/rpi-backup/fstab.line`): без диска малина грузится, бэкап просто пропускается (`mountpoint -q`).
- ⚠️ **Два 2.5" HDD от USB Pi 4 не тянут.** Проверено 2026-10-11: при раскрутке обоих — `over-current` на всех USB3-портах; под нагрузкой 4-ТБ диск отваливался (`USB disconnect`) и залипал в `Unit Not Ready`. Один диск стабилен (20 ГБ чтения 103 МБ/с, mkfs ~60 ГБ записи без сбросов). Второй диск — только через USB3-хаб с собственным питанием.
@@ -47,6 +47,10 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg
- Мониторинг: `router_mount_up` и заполнение `/mnt/router` (в `smart-metrics`, `df` под `timeout 20`), итог прогона — `backup_*{job="backup_router"}`; алерты `RouterDiskMountDown` (30 мин), `BackupDiskFull` (>90 %, любой диск бэкапа), `BackupStale` (>30 ч); ряд «Вторая копия» на дашборде rpi-disk.
- ⚠️ 1 ТБ меньше основного диска: сейчас влезает (~370 ГБ OneDrive + ~100 ГБ серверов), но архив версий растёт вечно — следить за `BackupDiskFull`.
- Почему NTFS (2026-10-11): прошивка Cyta видит FAT32 и NTFS, **exFAT — нет** (диск в списке USB не появлялся); FAT32 отпал из-за лимита 4 ГБ на файл. Файл 4,4 ГБ через SFTP записан и прочитан. На Mac NTFS штатно не создать — делали `mkntfs` из ntfs-3g, собранного без FUSE (`--disable-ntfs-3g`), на блочное `/dev/diskNs1` (на `rdisk` mkntfs отказывается).
- ⚠️ FTP роутера (порт 21) включён в его конфиге, но из LAN **filtered**: доступ к FTP со стороны LAN открывается только на странице ONT Access Control, а у пользователя `cytauser` её нет (нужен суперадмин Cyta). Поэтому SFTP: включён 2026-10-11 через веб-API роутера — сервер `8022`, LAN=1, **WAN=0**, аккаунт `root` с корнем `/mnt/usb/usb-1058-363434_1`. Пароли SFTP и веб-интерфейса — только у пользователя и в `rclone.conf`.
- ⚠️ Потолок ~10 МБ/с — CPU роутера (dropbear): `aes128/256-ctr` вдвое медленнее `chacha20` (нет аппаратного AES), параллельные потоки почти не помогают. Быстрее только диск на малине через хаб с питанием (~100 МБ/с).
## Бэкап серверов fr1/ru1 (с 2026-10-11, код — `stacks/server-backup/`)
- Малина — внешняя площадка для VPS: `servers-backup.timer` (02:15) забирает по SSH выгрузки, которые **сервер сам шифрует открытым ключом age** (`tar | zstd | age`). Закрытого ключа на малине нет — ни она, ни Astra, ни взломщик архивы не прочитают. Ключ у владельца: Mac `~/.config/age/servers-backup.key`.
- Хранилище `/mnt/backup/servers/<host>/{daily,weekly,monthly}` (root 700): 14 / 8 / 12, monthly — хардлинк первого daily месяца. fr1 daily ~2.1 ГБ (5.5 мин), ru1 ~17 МБ, fr1 weekly (вс) — тома ботов и imgbot.
@@ -62,16 +66,52 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg
- `smart-metrics` заодно отдаёт `backup_disk_size_bytes`/`backup_disk_avail_bytes` для `/mnt/backup` (statfs, диск не будит).
- Дашборд: https://mon.ruzzy.dev/d/rpi-disk (SMART, износ за сутки, свежесть бэкапов, заполнение).
- ⚠️ `BackupStale` видит только **существующие** серии — задача, ни разу не отчитавшаяся, им не ловится. Для этого `BackupNeverReported` на `absent()` (с 2026-10-11): OneDrive 12 ч, серверы 26 ч, роутер 30 ч, проверка OneDrive 8 дней; и `PushMetricsStale` на отсутствие SMART-метрик 1 ч. Доставка в Telegram проверена тестовым алертом 2026-10-11.
- **monwatch — сторож самого мониторинга** (с 2026-10-11, `stacks/rpi-backup/bin/monwatch`, таймер раз в час): весь мониторинг живёт на fr1, и если упадёт fr1 или цепочка доставки — алертов не будет вообще. Малина спрашивает fr1 `health` (тот же ключ метрик, режим только чтение): живы ли Prometheus/Alertmanager/pushgateway, видит ли Prometheus пуши малины моложе часа, не растут ли `alertmanager_notifications_failed_total{integration="telegram"}`. При проблеме (после повторной проверки через 2 мин) пишет в Telegram **напрямую, минуя fr1** — ботом @rasp_contol_bot (токен из `/etc/astra-bot.env`) в чат 607015; повтор раз в 6 ч, ✅ при восстановлении. Состояние — `/var/lib/monwatch/`. Путь тревоги проверен имитацией 2026-10-11 (`MONWATCH_MAX_PUSH_AGE=1`).
- ⚠️ Сообщения сторожа приходят в чат с ботом Astra — это бот сам пишет, Codex их не видит и не обрабатывает.
- Алерты — группа `rpi` в `stacks/monitoring/prometheus/rules/alerts.yml`.
## Домашняя печать (с 2026-10-11)
- Brother DCP-L2620DW обнаружен через mDNS: `192.168.10.24`, `BRW2C9C58C1EEA7.local`, IPP порт 631 `/ipp/print`; чёрно-белый, A4, двусторонняя печать. Ранее службы печати на rpi не было.
- Установлены `cups`, `cups-client`, `cups-filters`, `cups-ipp-utils` (CUPS 2.4.10); включён `cups.service`. Очередь по умолчанию `home-brother`, драйвер `everywhere`, URI `ipp://BRW2C9C58C1EEA7.local:631/ipp/print`. По умолчанию A4, одна сторона; `printer-is-shared=false`, CUPS слушает только `127.0.0.1:631` и `[::1]:631`. Доступ из Telegram — команда агента, без отдельного открытого веб-интерфейса.
- Код настройки и команда — `stacks/rpi-print/`; `home-print` установлен в `/usr/local/bin/` (root, 755). `home-print status` — состояние/очередь; `home-print [--duplex] <PDF/PNG/JPEG>` — одна копия, A4 с подгонкой. Печать только по прямой просьбе пользователя; приём задания в очередь не означает, что бумага уже вышла.
- **Навык `home-print` установлен 2026-10-11** в `~astra/.codex/skills/home-print/`; источник — [astra-bot `c01ed22`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/c01ed22), `codex/skills/home-print/`. Ранее обязательного предпросмотра не было. При просьбе печати извлекает текст из скриншотов и перевёрстывает чёрным по белому, обычные картинки сохраняет и готовит к ч/б печати, формирует PDF и PNG каждой страницы. Отправляет PNG в Telegram отдельным скриптом (токен только из `/etc/astra-bot.env`, без вывода). **Печатает только после явного подтверждения пользователем доставленного предпросмотра**, причём тот же PDF, из которого получены PNG. Исторические сведения не дописывает; неразборчивые/обрезанные места отмечает. Скрипты проверены на двухстраничном PDF, вся серия — 28 тестов; установленный навык валидирован. Подключение к новым разговорам (`/new`), перезапуск бота не нужен.
- **Навык `home-print` упрощён для ребёнка 2026-10-11**; источник — [astra-bot `11d0a85`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/11d0a85), `codex/skills/home-print/`, установлен в `~astra/.codex/skills/home-print/`. Вместо PNG каждой страницы и повторного подтверждения отправляет **один PDF**, затем по прямой просьбе «распечатай» печатает одну копию A4 с одной стороны без дополнительных вопросов. Текст извлекается из скриншотов, картинки сохраняются в оттенках серого, все страницы проверяются локально. Если PDF не доставлен, печать не запускается; повторная отправка задания при ошибке не автоматическая. Для этого сценария установлены узкие постоянные разрешения `~astra/.codex/rules/home-print.rules` (источник `codex/rules/` в astra-bot): только `/usr/local/bin/home-print` и PDF-режим штатного отправщика. Общая политика подтверждений сохранена. Проверка: 31 тест, валидация скилла и правил; произвольный Python правилами не разрешается. Инструкции установлены в `~/.codex/AGENTS.md`; новые разговоры получают их автоматически.
- **Кнопки и промежуточные ответы печати убраны в самом боте 2026-10-11**, [astra-bot `6b1317d`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/6b1317d). Ранее постоянных правил скилла было недостаточно: запросы approval и все agentMessage уходили в Telegram. Бот теперь автоматически отвечает accept только на штатную PDF-отправку разрешённому чату и home-print для print.pdf в каталогах /tmp/astra-print-*; shell-операторы и произвольные команды исключены. В задачах печати сохраняет последний ответ и отправляет его после завершения, PDF доставляется отдельным вложением. Проверено 34 тестами, установлен bot.py; прежний код — /opt/astra-bot/bot.py.bak-20261011-child-print. Перезапуск через restart-check.py после завершения текущего ответа.
- ⚠️ DOCX напрямую не печатается — сначала преобразовать в PDF. Hostname выбран вместо DHCP-IP, чтобы пережить смену адреса; требуется mDNS и включённый принтер. `printer-error-policy=abort-job` исключает неожиданную автоматическую печать старых заданий после ошибки; ошибочный прогон нужно отправить заново.
- Проверено: запрос атрибутов IPP (`ipptool`) PASS, принтер idle/enabled, A4/Gray/duplex доступны. Портрет успешно преобразован фильтрами CUPS PNG → PDF → PWG Raster (A4, 300 dpi), без ошибок. Тестовая страница A4 отправлена 2026-10-11 в 15:20 MSK: `home-brother-1`, CUPS сообщил `job-completed-successfully`, принтер вернулся в idle, очередь пуста; качество на бумаге проверяет пользователь.
- Для перевёрстки извлечённого текста в PDF установлен `python3-reportlab` 4.3.1 (2026-10-11; ранее средств создания PDF с кириллицей не было), с зависимостями Pillow/Cairo. Шрифт DejaVu Sans внедряется в PDF; PNG-предпросмотр строится из итогового серого PDF. Проверено на комплекте Лермонтова: 3 страницы A4, текст со скриншотов и исходный портрет.
## Медиацентр (с 2026-10-11, код — `stacks/rpi-media/`)
Docker CE 29.9 + Compose v5.6 из репо download.docker.com (в Debian старее). `/etc/docker/daemon.json`: json-file `10m×3` — чтобы логи контейнеров не точили SD. `rus` в группе `docker` (= root-эквивалент; у него и так sudo NOPASSWD). Стек — `/opt/media/docker-compose.yml`, конфиги — `/opt/media/config/<сервис>` (SD, ~20 МБ), образы ~7.8 ГБ на SD.
| Сервис | Порт | Зачем | Версия на 2026-10-11 |
|---|---|---|---|
| Jellyfin | 8096 (host-сеть) | медиасервер; клиенты LG webOS, Infuse/Swiftfin на iOS | 12.2 |
| Seerr | 5055 | каталог «хочу посмотреть» → Radarr/Sonarr | v3.5.0 |
| qBittorrent | 8080, торренты 6881 tcp/udp | загрузка | 5.2.4 |
| Radarr / Sonarr | 7878 / 8989 | фильмы / сериалы: поиск, загрузка, импорт | 6.4.4 / 4.0.20 |
| Prowlarr | 9696 | индексаторы, раздаёт их в Radarr/Sonarr | 2.6.5 |
| FlareSolverr | 8191 | обход Cloudflare для индексаторов с тегом `flaresolverr` | v3.5.2 |
| Bazarr | 6767 | субтитры, профиль `ru+en` | v1.6.2 |
| Homepage | 3000 | **стартовая страница**: все сервисы с описанием по-русски, живой статус, CPU/температура/диск | v2.5.0 |
- **Данные — `/mnt/backup/media`** (4-ТБ диск, решение пользователя): `torrents/{movies,tv,other}`, `library/{movies,tv}`, `.cache/jellyfin` (кэш и транскоды Jellyfin — не на SD). qBittorrent и *arr монтируют **один и тот же** корень как `/data` — иначе импорт из `torrents/` в `library/` превращается из хардлинка в копию и раздача занимает место дважды. `copyUsingHardlinks` включён. В бэкапы (`router-backup`, `onedrive-*`) `media/` не входит.
- ⚠️ **Медиа делят диск с бэкапами.** `onedrive-backup` падает с кодом 2 при заполнении >90 % (~370 ГБ свободно) → `BackupFailed`. В Radarr/Sonarr стоит `minimumFreeSpaceWhenImporting = 500 ГБ`: ниже этого импорт останавливается. **qBittorrent своего порога не имеет** — ручная заливка торрентов мимо *arr может забить диск.
- Jellyfin в `network_mode: host` — иначе LG/iOS не находят сервер автообнаружением (UDP 7359 и DLNA-мультикаст не проходят через бридж). Аппаратного транскодинга на Pi 4 нет (V4L2 убран в Jellyfin 10.9), `EncodingThreadCount=3`: расчёт на Direct Play. ⚠️ Транскод 4K/HEVC Pi не тянет — клиенту нужен нативный кодек (Infuse играет почти всё сам).
- Метаданные и интерфейс — ru, предпочтительные аудио/субтитры у пользователя — `rus`, режим субтитров Smart. Библиотеки «Фильмы» (`/data/library/movies`) и «Сериалы» (`/data/library/tv`), в Jellyfin только чтение.
- Индексаторы (публичные, без аккаунтов): NoNaMe Club, The Pirate Bay, YTS — синхронизированы в Radarr/Sonarr; Rutor и EZTV есть в Prowlarr, но в *arr не доехали (Rutor не проходит тест по категориям, EZTV на момент настройки лежал); 1337x не добавился (таймаут даже через FlareSolverr). **RuTracker** (добавлен 2026-10-11, аккаунт пользователя; логин/пароль — только в БД Prowlarr): через FlareSolverr, опции «убрать кириллицу», «добавить RUS», «первые теги в конец» — чтобы *arr сопоставляли английские названия и видели русскую дорожку; приоритет 10 (выше публичных, 25). Синхронизирован в Radarr и Sonarr. Kinozal и прочие приватные — руками.
- ⚠️ RuTracker закрывает `login.php`/`tracker.php` челленджем Cloudflare (по IPv4; IPv6 у малины нет маршрута). FlareSolverr на Pi решает его **80–180 с** — у прокси в Prowlarr `requestTimeout=180` (было 60, с ним добавление падало по таймауту). После решения Prowlarr держит cookie и поиск идёт ~4 с; когда cookie протухнет, первый поиск снова будет минутами, и *arr могут на время пометить индексатор недоступным.
- Bazarr: провайдеры без аккаунтов (`embeddedsubtitles`, `podnapisi`, `yifysubtitles`); для русских субтитров лучший — OpenSubtitles.com, нужен аккаунт.
- **Авторизация:** везде пользователь `rus`, один пароль — `/opt/media/.admin-password` (rus 600). *arr — формы, пароль **всегда** (режим «без пароля из LAN» в новых *arr требует `AllowedHosts`). Между собой сервисы ходят по API-ключам из `config/<app>/config.xml`.
- **Точка входа для пользователя — http://192.168.10.25:3000** (Homepage, конфиги `stacks/rpi-media/homepage/` → `/opt/media/homepage/`). Ключи виджетов — `/opt/media/homepage.env` (rus 600, `HOMEPAGE_VAR_*`, генерит `provision.py homepage`). ⚠️ Там же пароль qBittorrent: после смены общего пароля перегенерировать env и `docker compose up -d homepage`, иначе виджет qBittorrent отвалится. `HOMEPAGE_ALLOWED_HOSTS` обязателен — без него 400 на любой Host, кроме localhost.
- ⚠️ Встроенный виджет `jellyfin` Homepage 2.5 с Jellyfin 12 не работает: ходит на `/emby/…?api_key=` — пути нет (404), ключ в query отключён (`EnableLegacyAuthorization=false`, включать не стали). Вместо него `customapi` на `/Items/Counts` с заголовком `Authorization: MediaBrowser Token=…`.
- Radarr/Sonarr → Jellyfin (Connect «Jellyfin», тип MediaBrowser, ключ Jellyfin `homepage`; `provision.py connect` на чистой установке заводит ключ `arr-connect`): библиотека обновляется сразу после импорта. Без этого новый фильм висел невидимым до скана по расписанию — realtime-монитор хардлинк не заметил (проверено 2026-10-11 на первом фильме).
- Первичная связка — `stacks/rpi-media/provision.py` (идемпотентный, пароль и ключи читает на месте): `python3 /opt/media/provision.py [qbit jellyfin arr prowlarr bazarr seerr connect homepage]`.
- ⚠️ Каталог `config/<сервис>`, которого нет до `up`, docker создаёт **от root** — Jellyfin/Seerr (uid 1000) падают с `Permission denied`. Новые каталоги создавать заранее или `chown -R rus:rus /opt/media/config`.
- ⚠️ `fallenbagel/jellyseerr` заброшен (последний образ 2025-08) и не логинится в Jellyfin 12 — заменён на `ghcr.io/seerr-team/seerr`.
- Торренты идут **напрямую** с домашнего IP (решение пользователя 2026-10-11; вариант через AWG fr1 отложен). Порт 6881 на роутере **не проброшен** — входящих соединений нет, раздача и скорость хуже.
- Образы на `:latest`, автообновления нет: `cd /opt/media && docker compose pull && docker compose up -d`.
## Агент Astra (с 2026-10-11)
Агент на базе **Codex** (OpenAI), работает на самой малине, управление — из Telegram-бота **@rasp_contol_bot**. Код бота — отдельный репо **`ruzzy/astra-bot`** (там же схема работы).
- Пользователь `astra` (группы `sudo`, `adm`, `systemd-journal`, `users`), пароль заблокирован, **sudo NOPASSWD** (`/etc/sudoers.d/90-astra`) — по решению пользователя. SSH-вход под ним — ключом `~/.ssh/id_ed25519` с Mac (алиас `ssh rpi-astra`).
@@ -79,7 +119,7 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg
- **Codex CLI 0.162.1** — полный пакет в `/opt/codex`, симлинк `/usr/local/bin/codex`. ⚠️ Голый бинарник из релиза (`codex-aarch64-…tar.gz`) не годится: `remote-control`/app-server требуют пакет (`codex-package-aarch64-unknown-linux-musl.tar.gz`), иначе «this CLI has no complete local package». Логин — подпиской ChatGPT (`codex login --device-auth`, аккаунт ruzzyrullezz@gmail.com, Plus), токен `~astra/.codex/auth.json` (600). Ключ API не используется.
- **Агенту разрешено пушить в `main` `ruzzy/infra` напрямую** (решение пользователя 2026-10-11, перекрывает правило из `CLAUDE.md` только для него): любые изменения сервера он обязан внести в `servers/rpi.md`/`stacks/` и запушить в том же прогоне, конфликты — `pull --rebase` без `--force`, неразрешимые — отдать пользователю. Источник инструкций — `codex/AGENTS.md` в `ruzzy/astra-bot`.
- Конфиг `~astra/.codex/config.toml`: `approval_policy=on-request`, `sandbox_mode=workspace-write`; инструкции агенту — `~astra/.codex/AGENTS.md` (паспорт, правила infra, что не трогать).
- **astra-bot** (`astra-bot.service`, user `astra`): `/opt/astra-bot/bot.py` ⇄ `codex app-server` (JSON-RPC stdio) ⇄ Telegram long polling. Подтверждения команд/правок — кнопками в чате. Конфиг `/etc/astra-bot.env` (root 600): `TG_TOKEN`, `TG_ALLOWED_IDS=607015`. Тред Codex на чат — `~astra/.local/state/astra-bot.json`. Бот лежит в `/opt` под root, а не в `~/infra`, чтобы агент не правил свой код без `sudo`.
- **astra-bot** (`astra-bot.service`, user `astra`): `/opt/astra-bot/bot.py` ⇄ `codex app-server` (JSON-RPC stdio) ⇄ Telegram long polling. Подтверждения команд/правок — кнопками в чате. Конфиг `/etc/astra-bot.env` (root 600): `TG_TOKEN`, `TG_ALLOWED_IDS=607015,6310034441`. Тред Codex на чат — `~astra/.local/state/astra-bot.json`. Бот лежит в `/opt` под root, а не в `~/infra`, чтобы агент не правил свой код без `sudo`.
- **Приём вложений исправлен 2026-10-11** (раньше бот читал только `text`, фото и подписи молча пропадали): код — `ruzzy/astra-bot`. Фото и JPEG/PNG/WebP-документы до 20 МиБ скачиваются через Telegram `getFile` в `~astra/.local/share/astra-bot/incoming/` (файлы 600, UUID), передаются Codex как `localImage`; подпись и источник пересылки — текстом. Пересланные `/stop` и другие команды читаются как цитата. Во время работы сообщения ставятся в очередь процесса, реальная доставка картинки с подписью и источником подтверждена пользователем 2026-10-11.
- **Пачки изображений добавлены 2026-10-11** (раньше каждый элемент альбома запускал отдельную задачу и отдельное уведомление очереди): установлен [коммит `6276bf8`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/6276bf8). Бот собирает альбом по `media_group_id`/чату/отправителю, быстро пересланные картинки без ID — по чату/отправителю; после 1,5 секунды тишины передаёт все картинки, подписи и источники одним запросом Codex в порядке `message_id`. Служебные уведомления ожидания убраны 2026-10-11 ([коммит `c4e3cd1`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/c4e3cd1)): прежняя проверка пустой очереди повторяла их между пачками; очередь сохранена и работает молча. Проверка включает повторное заполнение опустевшей очереди. Предыдущий код — `/opt/astra-bot/bot.py.bak-20261011-silent-queue`. 23 теста прошли; изменение загружается при перезапуске через `restart-check.py`. ⚠️ Пауза больше 1,5 секунды разделяет серию на пачки; буфер и очередь не переживают перезапуск. Предыдущий код — `/opt/astra-bot/bot.py.bak-20261011-batches`.
- Код бота с доработкой Astra перенесён в `ruzzy/astra-bot` (`f9f6c39`; текущая установленная версия — `c4e3cd1`); временная копия `stacks/astra-bot/` удалена 2026-10-11. Клон — `~astra/astra-bot`. Резервная копия прежнего кода — `/opt/astra-bot/bot.py.bak-20261011-incoming`.
@@ -87,9 +127,24 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg
- `/boot/firmware/cmdline.txt`: добавлен `cfg80211.ieee80211_regdom=RU` (бэкап `.bak-20261011`) — только чтобы `/etc/profile.d/wifi-check.sh` не печатал в stdout login-shell предупреждение про rfkill: оно ломало бы stdio-протоколы (Codex app-server по SSH). Wi-Fi не используется.
- **Доступ к Gitea — пользователь `astra`** (с 2026-10-11, вместо двух deploy-ключей): один SSH-ключ `~astra/.ssh/gitea`, коллаборатор с записью в `ruzzy/infra` (`~astra/infra`) и `ruzzy/astra-bot` (`~astra/astra-bot`), может создавать свои репо в `astra/…` (API-токен `~astra/.config/gitea/token`, 600, scopes `write:repository,write:user`, в Gitea — `astra-rpi`). Почему пользователь, а не deploy-ключи: deploy-ключом нельзя создавать репо, а права коллаборатора видны и меняются в настройках репо. Создавать репо от имени `ruzzy` нельзя — он пользователь, не организация. git identity `Astra (rpi) <astra@rpi.ruzzy.dev>`, host key Gitea (RSA) закреплён. Автопулла нет.
- Профиль Поли (@Polli4ka303), 10 лет, сохранён в инструкциях 2026-10-11: простые короткие ответы без технических терминов; источник [astra-bot `88b88a5`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/88b88a5). Детская печать уточнена: единый PDF → «Печатай» / «Печатай на двух сторонах» → печать без системных кнопок. Доступ добавлен 2026-10-11: Telegram ID `6310034441` внесён в TG_ALLOWED_IDS (ранее только владелец `607015`), профиль закреплён за ID вместо username; [astra-bot `2d761d3`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/2d761d3). Настройка применяется после перезапуска бота.
- Постоянная сессия и журнал Поли добавлены 2026-10-11, [astra-bot `8b3400a`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/8b3400a). После авторизации её чат сохраняется в state.child_chats, тред возобновляется после перезапуска; служебные slash-команды не сбрасывают разговор и не показывают справку администратора. Бот передаёт возраст и текущий chat_id в контекст, отправляет только итоговый ответ. Входящие сообщения, метаданные/пути вложений и текстовые ответы пишутся в ~/.local/share/astra-bot/conversations/<chat_id>.jsonl (файл 600, каталог 700); PDF, отправленный отдельным скриптом, в этот журнал не записывается. Имя пользователя выбирает профиль только после проверки TG_ALLOWED_IDS и не предоставляет доступ. Проверка: 37 тестов; резервный код /opt/astra-bot/bot.py.bak-20261011-child-session. ⚠️ При невозможности resume бот создаёт новый тред; локальный журнал остаётся, автоматического импорта журнала в контекст нет.
## Роутер Huawei HG8245X6 — скилл `router` (с 2026-10-11)
- Роутер — **OptiXstar HG8245X6-10**, оптический терминал Cyta (GPON), ПО V5R022C10S110, `192.168.10.254`. Интернет — PPPoE `1_INTERNET_R_VID_42` (публичный IP динамический, на 2026-10-11 — 46.199.208.45); ещё WAN TR069, VOIP, IPTV. За LAN3 — mesh-точки M15/E15 (Wi-Fi-клиенты за ними видны как `lan3`), на LAN4 — малина.
- Управление — Codex-скилл Astra `router`, код в `ruzzy/astra-bot` (`codex/skills/router/`), команда `/usr/local/bin/router`. Креды веб-интерфейса — `/etc/router.env` (root:astra 640), в git нет. Умеет: статус/WAN, клиенты (IPv4 из аренд DHCP, сигнал Wi-Fi), трафик по интерфейсам, DHCP static add/del, проброс портов add/del (через WAN INTERNET), Wi-Fi — только чтение. Изменения — только `--yes` и кнопкой (`codex/rules/router.rules` разрешает лишь чтение).
- ⚠️ **3 неудачных входа подряд блокируют вход в роутер** (`errloginlockNum = '3'`). `login.cgi` разбирает поля позиционно — порядок как у браузера (UserName, PassWord, Language, x.X_HW_Token), иначе вход «неудачный». login не ретраится. Веб-сервер однопоточный и рвёт соединения при частых запросах — чтение ретраится с паузой; выход (`logout.cgi`) всегда отвечает обрывом, это норма. После каждой операции — выход, чтобы не забивать лимит сессий и не выбивать человека из веб-интерфейса.
- ⚠️ Трафик **по устройствам прошивка не считает** (поля `TrafficRecvRate/SendRate` всегда 0) — только по интерфейсам: `wan` (сумма GEM Ethernet), `iptv`, `lan1..4`, `wifi-2.4`, `wifi-5`.
- ⚠️ Запись Wi-Fi не сделана сознательно: SSID 2.4/5 ГГц связаны (WifiCover/band steering), запрос — десятки полей; ошибка отключит весь дом. Делать отдельно, в согласованное окно.
- `router-watch.timer` (5 мин, root): снимок `/var/lib/router-watch/state.json` (читает astra без сети), `seen.json` (все MAC с первым появлением; база — 22 устройства на 2026-10-11 15:57), `known.json` (имена от пользователя), `events.log`. Новый MAC → Telegram ботом @rasp_contol_bot с флагами (случайный MAC, ночь, без имени, ≥3 новых за час). Метрики `router_*` → pushgateway (job `router`) → алерты группы `home-network`: `RouterWatchStale` (>20 мин), `RouterDownloadSpike`/`RouterUploadSpike` (WAN: 15-мин скорость >4× средней за неделю и >5 МБ/с ↓ / >1 МБ/с ↑, 30 мин). Дашборд https://mon.ruzzy.dev/d/home-net.
- Проверено на живом роутере 2026-10-11: чтение всех разделов; `dhcp add/del` (тестовая привязка 02:00:00:00:00:01 → .250 создана и удалена), `portmap add/del` (TCP 59999 → rpi создан и удалён).
- Статическая привязка DHCP `192.168.10.25` ↔ `dc:a6:32:db:b0:f9` (малина) на роутере уже есть — единственная. Привязка `192.168.10.101` ↔ `8e:a9:3a:4e:69:ba` удалена 2026-10-11: устройства с таким MAC нет ни в сети, ни в истории роутера (вероятно, старый телефон со сменившимся случайным MAC).
- Свободная беседа, интернет-поиск, объяснение школьного материала, поиск и генерация картинок для Поли разрешены 2026-10-11; правила установлены в ~/.codex/AGENTS.md, источник [astra-bot `95a2867`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/95a2867). Содержание подбирается для 10 лет, материалы 16+/18+ исключаются; выбранные картинки проверяются перед отправкой. Это инструкции агента, не отдельный сетевой фильтр. Разрешение не распространяется на управление сервером и доступами.
## Открытые хвосты (на 2026-10-11)
- ⏳ Закрепить `192.168.10.25` за MAC `dc:a6:32:db:b0:f9` на роутере.
- ⏳ USB3-хаб с питанием → подключить и отформатировать WD Elements 1 ТБ (вторая копия архива — сейчас копия одна).
- USB3-хаб с питанием — теперь только ради скорости: WD Elements 1 ТБ работает второй копией в роутере (~10 МБ/с); на малине через хаб было бы ~100 МБ/с (обновлено 2026-10-11, было «копия одна»).
- Нет node-exporter (CPU/RAM/диск самой малины в Prometheus не видны) — пуш через pushgateway для node-метрик неудобен; решить, нужен ли.
- Первый прогон бэкапа стартовал до появления `ExecStopPost` — его итог может не дойти в метрики; `backup_last_success_timestamp` появится с первого прогона по таймеру. До этого `BackupStale` молчит (серии нет).
- ⏳ `codex-remote.service` (демон `codex remote-control` с `RUST_LOG=debug`) всё ещё включён и работает, хотя сопряжение не взлетело — убрать или оставить ждать поддержки Linux от OpenAI (решение за пользователем).
@@ -100,3 +155,4 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg
- Через неделю снять SMART и посчитать реальный прирост Start_Stop/Load_Cycle — прогноз ресурса пока оценочный.
- ⏳ Определить хранение входящих фото: сейчас автоматически не удаляются ради ссылок в старых тредах; очередь сообщений только в памяти и теряется при перезапуске.
- Медиацентр: проброс 6881 tcp/udp на роутере (раздача/скорость); пароль SFTP роутера слабый (только LAN) — сменить; Kinozal и OpenSubtitles — аккаунты пользователя; решить про автообновление образов.
@@ -0,0 +1,323 @@
{
"uid": "home-net",
"title": "Домашняя сеть (роутер)",
"tags": [
"rpi",
"router"
],
"timezone": "browser",
"schemaVersion": 39,
"refresh": "5m",
"time": {
"from": "now-24h",
"to": "now"
},
"editable": true,
"templating": {
"list": [
{
"name": "iface",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"query": {
"query": "label_values(router_iface_bytes_total, iface)",
"refId": "i"
},
"refresh": 2,
"includeAll": true,
"multi": true,
"current": {
"text": "wan",
"value": "wan"
}
}
]
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "Клиентов онлайн",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 6,
"h": 4
},
"description": "",
"targets": [
{
"refId": "A",
"expr": "router_clients_online",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "none",
"graphMode": "area",
"textMode": "value"
}
},
{
"id": 2,
"type": "stat",
"title": "Онлайн без имени (не в known.json)",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 6,
"y": 0,
"w": 6,
"h": 4
},
"description": "Устройства, которым ещё не дали имя через `router known add`.",
"targets": [
{
"refId": "A",
"expr": "router_clients_unknown_online",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "none",
"graphMode": "area",
"textMode": "value"
}
},
{
"id": 3,
"type": "stat",
"title": "Wi-Fi 2.4 ГГц",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 0,
"w": 6,
"h": 4
},
"description": "",
"targets": [
{
"refId": "A",
"expr": "router_wifi_stations{band=\"wifi-2.4\"}",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "none",
"graphMode": "area",
"textMode": "value"
}
},
{
"id": 4,
"type": "stat",
"title": "Wi-Fi 5 ГГц",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 18,
"y": 0,
"w": 6,
"h": 4
},
"description": "",
"targets": [
{
"refId": "A",
"expr": "router_wifi_stations{band=\"wifi-5\"}",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "none",
"graphMode": "area",
"textMode": "value"
}
},
{
"id": 5,
"type": "timeseries",
"title": "Загрузка (↓ rx) по интерфейсам",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 9
},
"targets": [
{
"refId": "A",
"expr": "rate(router_iface_bytes_total{iface=~\"$iface\",dir=\"rx\"}[5m])",
"legendFormat": "{{iface}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "Bps",
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": true
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
}
}
},
{
"id": 6,
"type": "timeseries",
"title": "Отдача (↑ tx) по интерфейсам",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 9
},
"targets": [
{
"refId": "A",
"expr": "rate(router_iface_bytes_total{iface=~\"$iface\",dir=\"tx\"}[5m])",
"legendFormat": "{{iface}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "Bps",
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": true
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
}
}
},
{
"id": 7,
"type": "timeseries",
"title": "Клиенты онлайн",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 13,
"w": 24,
"h": 7
},
"targets": [
{
"refId": "A",
"expr": "router_clients_online",
"legendFormat": "всего"
},
{
"refId": "B",
"expr": "router_clients_unknown_online",
"legendFormat": "без имени"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": true
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
}
}
}
]
}
@@ -157,3 +157,35 @@ groups:
labels: {severity: warning, instance: rpi, job: backup_router_sample}
annotations:
summary: "rpi: ежемесячная перечитка файлов с диска роутера не отчитывалась 35+ дней — проверь router-sample"
# Домашняя сеть: роутер Huawei HG8245X6 (ONT Cyta), сторож router-watch на rpi раз в 5 мин.
# По отдельным устройствам прошивка трафик не считает — только по интерфейсам (wan, lan*, wifi-*).
- name: home-network
rules:
- alert: RouterWatchStale
expr: time() - push_time_seconds{job="router"} > 20 * 60
for: 10m
labels: {severity: warning}
annotations:
summary: "rpi: сторож роутера не присылал данных {{ $value | humanizeDuration }} — router-watch или вход на роутер сломан"
# всплеск = скорость за 15 мин в 4+ раза выше средней за неделю И выше абсолютного порога
- alert: RouterDownloadSpike
expr: |
rate(router_iface_bytes_total{iface="wan",dir="rx"}[15m]) > 5e6
and rate(router_iface_bytes_total{iface="wan",dir="rx"}[15m])
> 4 * avg_over_time(rate(router_iface_bytes_total{iface="wan",dir="rx"}[1h])[7d:1h])
for: 30m
labels: {severity: warning}
annotations:
summary: "Дом: необычно большая загрузка из интернета — {{ $value | humanize1024 }}Б/с 30+ мин. Кто качает: `router traffic --live` (по портам/Wi-Fi). Сама малина качает OneDrive/бэкапы — сверь с их расписанием."
- alert: RouterUploadSpike
expr: |
rate(router_iface_bytes_total{iface="wan",dir="tx"}[15m]) > 1e6
and rate(router_iface_bytes_total{iface="wan",dir="tx"}[15m])
> 4 * avg_over_time(rate(router_iface_bytes_total{iface="wan",dir="tx"}[1h])[7d:1h])
for: 30m
labels: {severity: warning}
annotations:
summary: "Дом: необычно большая ОТДАЧА в интернет — {{ $value | humanize1024 }}Б/с 30+ мин (облачная синхронизация, торрент или что-то подозрительное). Кто: `router traffic --live`."
+28
View File
@@ -2,9 +2,37 @@
# Forced command для пуш-ключей хостов за NAT (authorized_keys: command="~/bin/pushgw-ingest <instance>").
# Клиент передаёт только имя job (ssh ... <job>), метрики — в stdin в text-формате Prometheus.
# instance задаётся ключом, а не клиентом: хост не может писать метрики от чужого имени.
#
# Особый «job» `health` — только чтение: внешний сторож (rpi, monwatch) раз в час проверяет,
# что сам мониторинг fr1 жив. Отдаёт строки key=value, ничего не меняет.
instance="$1"
job="$SSH_ORIGINAL_COMMAND"
case "$instance" in ""|*[!a-z0-9_-]*) echo "bad instance" >&2; exit 2;; esac
if [ "$job" = health ]; then
code() { curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1"; }
echo "prometheus_healthy=$(code http://127.0.0.1:9090/-/healthy)"
echo "alertmanager_healthy=$(code http://127.0.0.1:9093/-/healthy)"
echo "pushgateway_healthy=$(code http://127.0.0.1:9091/-/healthy)"
# видит ли Prometheus свежие пуши этого хоста — сквозная проверка pushgateway → scrape → TSDB
age=$(curl -s --max-time 10 http://127.0.0.1:9090/api/v1/query \
--data-urlencode "query=time() - max(push_time_seconds{instance=\"$instance\"})" \
| python3 -c 'import json,sys
try: print(int(float(json.load(sys.stdin)["data"]["result"][0]["value"][1])))
except Exception: print(-1)')
echo "push_age_seconds=$age"
# счётчики доставки в Telegram (монотонные; сторож сравнивает с прошлым замером)
curl -s --max-time 10 http://127.0.0.1:9093/metrics | awk '
/^alertmanager_notifications_failed_total\{integration="telegram"/ {f+=$2}
/^alertmanager_notifications_total\{integration="telegram"/ {t+=$2}
END {printf "tg_notifications_total=%d\ntg_notifications_failed_total=%d\n", t, f}'
firing=$(curl -s --max-time 10 http://127.0.0.1:9093/api/v2/alerts?active=true | python3 -c 'import json,sys
try: print(len(json.load(sys.stdin)))
except Exception: print(-1)')
echo "alerts_active=$firing"
exit 0
fi
case "$job" in ""|*[!a-z0-9_]*) echo "bad job" >&2; exit 2;; esac
exec curl -sS --fail-with-body --max-time 30 -X PUT --data-binary @- \
"http://127.0.0.1:9091/metrics/job/$job/instance/$instance"
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# monwatch — внешний сторож мониторинга fr1 («кто сторожит сторожа»).
# Весь мониторинг (Prometheus, Alertmanager → Telegram) живёт на fr1: если упадёт fr1 или сломается
# цепочка доставки, никаких алертов не будет вообще. Раз в час малина спрашивает fr1 `health`
# (тот же ограниченный ключ метрик, режим только чтение) и при проблеме пишет в Telegram
# НАПРЯМУЮ — через своего бота (@rasp_contol_bot, токен из /etc/astra-bot.env), минуя fr1.
#
# Тревога — при поломке, повтор раз в 6 ч, ✅ при восстановлении. Разовый сбой сети
# не тревожит: перед тревогой одна повторная проверка через 2 минуты.
set -uo pipefail
STATE=/var/lib/monwatch; mkdir -p "$STATE"
CHAT=${MONWATCH_CHAT:-607015}
REPEAT=$((6 * 3600))
MAX_PUSH_AGE=${MONWATCH_MAX_PUSH_AGE:-3600} # smart-metrics пушит каждые 15 мин
health() {
timeout 60 ssh -i /etc/metrics-push/id_ed25519 -o UserKnownHostsFile=/etc/metrics-push/known_hosts \
-o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 rus@161.97.93.252 health 2>&1
}
check() { # печатает проблемы по строке; пусто = всё хорошо
local out rc
out=$(health); rc=$?
if [ $rc -ne 0 ] || ! grep -q '^prometheus_healthy=' <<< "$out"; then
echo "fr1 не отвечает на проверку (ssh rc=$rc): $(tail -1 <<< "$out")"; return
fi
declare -A v; while IFS='=' read -r k val; do v[$k]=$val; done <<< "$out"
[ "${v[prometheus_healthy]}" = 200 ] || echo "Prometheus не отвечает (HTTP ${v[prometheus_healthy]})"
[ "${v[alertmanager_healthy]}" = 200 ] || echo "Alertmanager не отвечает (HTTP ${v[alertmanager_healthy]}) — алерты не доставляются"
[ "${v[pushgateway_healthy]}" = 200 ] || echo "Pushgateway не отвечает (HTTP ${v[pushgateway_healthy]})"
local age=${v[push_age_seconds]:--1}
if [ "$age" -lt 0 ] || [ "$age" -gt $MAX_PUSH_AGE ]; then
echo "Prometheus не видит свежих метрик малины ($([ "$age" -lt 0 ] && echo 'нет данных' || echo "$((age / 60)) мин"))"
fi
local failed=${v[tg_notifications_failed_total]:-0} prev
prev=$(cat "$STATE/tg_failed" 2>/dev/null || echo "$failed")
[ "$failed" -gt "$prev" ] && echo "Alertmanager не смог отправить в Telegram $((failed - prev)) уведомл. за последний час"
echo "$failed" > "$STATE/tg_failed"
}
tg() {
local token
token=$(sed -n 's/^TG_TOKEN=//p' /etc/astra-bot.env)
curl -s -o /dev/null -w '%{http_code}' --max-time 30 "https://api.telegram.org/bot$token/sendMessage" \
--data-urlencode "chat_id=$CHAT" --data-urlencode "text=$1" | grep -q 200 || echo "не удалось отправить в Telegram" >&2
}
problems=$(check)
if [ -n "$problems" ]; then
sleep 120; problems=$(check) # повторная проверка: не тревожим из-за разового сбоя сети
fi
now=$(date +%s); state=$(cat "$STATE/state" 2>/dev/null || echo up); last=$(cat "$STATE/last_alert" 2>/dev/null || echo 0)
if [ -n "$problems" ]; then
echo "DOWN: $problems"
if [ "$state" != down ] || [ $((now - last)) -ge $REPEAT ]; then
tg "🔴 Мониторинг fr1 сломан — алерты от него могут не приходить.
$problems
(сторож на rpi, проверка раз в час; повтор через 6 ч)" && echo "$now" > "$STATE/last_alert"
fi
echo down > "$STATE/state"
exit 1
fi
echo "OK: мониторинг fr1 в порядке"
[ "$state" = down ] && tg "✅ Мониторинг fr1 снова в порядке (сторож на rpi)."
echo up > "$STATE/state"
@@ -0,0 +1,9 @@
[Unit]
Description=Watchdog for fr1 monitoring (alerts to Telegram directly, bypassing fr1)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/monwatch
TimeoutStartSec=10min
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Hourly check that fr1 monitoring is alive
[Timer]
OnBootSec=5min
OnCalendar=hourly
RandomizedDelaySec=2min
Persistent=true
[Install]
WantedBy=timers.target
+130
View File
@@ -0,0 +1,130 @@
# Медиацентр на rpi. Разворачивается в /opt/media (docker compose up -d).
# Подробности и грабли — servers/rpi.md → «Медиацентр».
#
# Все *arr и qBittorrent видят один и тот же корень /data (= /mnt/backup/media),
# иначе импорт из torrents/ в library/ превращается в копирование вместо хардлинка
# и раздача занимает место дважды.
x-common: &common
restart: unless-stopped
environment: &env
PUID: "1000"
PGID: "1000"
TZ: Europe/Moscow
UMASK: "002"
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
# host-сеть: иначе LG/iOS не находят сервер автообнаружением (UDP 7359 и DLNA-мультикаст
# не проходят через docker-бридж)
network_mode: host
user: "1000:1000"
restart: unless-stopped
environment:
TZ: Europe/Moscow
JELLYFIN_PublishedServerUrl: http://192.168.10.25:8096
volumes:
- ./config/jellyfin:/config
# кэш и транскоды — на HDD, а не на SD
- /mnt/backup/media/.cache/jellyfin:/cache
- /mnt/backup/media/library:/data/library:ro
qbittorrent:
<<: *common
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
environment:
<<: *env
WEBUI_PORT: "8080"
TORRENTING_PORT: "6881"
volumes:
- ./config/qbittorrent:/config
- /mnt/backup/media:/data
ports:
- "8080:8080"
- "6881:6881"
- "6881:6881/udp"
prowlarr:
<<: *common
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
volumes:
- ./config/prowlarr:/config
ports:
- "9696:9696"
flaresolverr:
# нужен Prowlarr для трекеров за Cloudflare (часть публичных)
image: ghcr.io/flaresolverr/flaresolverr:latest
container_name: flaresolverr
restart: unless-stopped
environment:
TZ: Europe/Moscow
LOG_LEVEL: info
ports:
- "8191:8191"
sonarr:
<<: *common
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
volumes:
- ./config/sonarr:/config
- /mnt/backup/media:/data
ports:
- "8989:8989"
radarr:
<<: *common
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
volumes:
- ./config/radarr:/config
- /mnt/backup/media:/data
ports:
- "7878:7878"
bazarr:
<<: *common
image: lscr.io/linuxserver/bazarr:latest
container_name: bazarr
volumes:
- ./config/bazarr:/config
- /mnt/backup/media/library:/data/library
ports:
- "6767:6767"
seerr:
# Seerr = слившиеся Jellyseerr + Overseerr. ⚠️ образ fallenbagel/jellyseerr заброшен
# (последний — 2025-08) и не логинится в Jellyfin 12 (400 на AuthenticateByName)
image: ghcr.io/seerr-team/seerr:latest
container_name: seerr
init: true
restart: unless-stopped
environment:
TZ: Europe/Moscow
volumes:
- ./config/seerr:/app/config
ports:
- "5055:5055"
homepage:
# стартовая страница со всеми сервисами, описаниями и живым статусом
image: ghcr.io/gethomepage/homepage:latest
container_name: homepage
restart: unless-stopped
env_file: ./homepage.env # HOMEPAGE_VAR_* — API-ключи и пароль qBittorrent, rus 600, только на малине
environment:
TZ: Europe/Moscow
PUID: "1000"
PGID: "1000"
# без этого отдаёт 400 на любой Host, кроме localhost
HOMEPAGE_ALLOWED_HOSTS: 192.168.10.25:3000,rpi.local:3000,localhost:3000
volumes:
- ./homepage:/app/config
- /mnt/backup:/mnt/backup:ro # только чтобы виджет resources видел заполнение диска
ports:
- "3000:3000"
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
{}
+99
View File
@@ -0,0 +1,99 @@
# Ключи — из /opt/media/homepage.env (HOMEPAGE_VAR_*), в репо их нет.
# Ссылки (href) — для браузера, адрес малины; виджеты ходят изнутри docker-сети по именам
# контейнеров, кроме Jellyfin (он в host-сети).
- Как это работает:
- 1. Найти:
icon: mdi-magnify
description: "Открой Seerr, найди фильм или сериал (можно по-русски) и нажми Request"
- 2. Скачать:
icon: mdi-download
description: "Radarr/Sonarr сами ищут раздачу на трекерах, qBittorrent качает. Минуты–час"
- 3. Смотреть:
icon: mdi-television-play
description: "Готовое появляется в Jellyfin; субтитры ru/en Bazarr докачает сам"
- Если застряло:
icon: mdi-help-circle-outline
description: "Radarr/Sonarr → Activity → Queue; красная карточка = раздачи нет → Interactive Search"
- Смотреть:
- Jellyfin:
icon: jellyfin.svg
href: http://192.168.10.25:8096
description: "Медиатека. На LG — приложение Jellyfin, на iPhone/iPad — Infuse или Swiftfin, сервер 192.168.10.25:8096"
# ⚠️ встроенный виджет jellyfin (Homepage 2.5) ходит на /emby/…?api_key= — в Jellyfin 12 этого
# пути нет (404), а ключ в query отключён (EnableLegacyAuthorization=false). Поэтому customapi.
widget:
type: customapi
url: http://192.168.10.25:8096/Items/Counts
headers:
Authorization: 'MediaBrowser Token="{{HOMEPAGE_VAR_JELLYFIN_KEY}}"'
mappings:
- field: MovieCount
label: Фильмы
format: number
- field: SeriesCount
label: Сериалы
format: number
- field: EpisodeCount
label: Серии
format: number
- Seerr:
icon: jellyseerr.svg
href: http://192.168.10.25:5055
description: "Каталог «хочу посмотреть»: Request → скачается само. Статусы: Processing → Available"
widget:
type: jellyseerr
url: http://seerr:5055
key: "{{HOMEPAGE_VAR_SEERR_KEY}}"
- Добавить фильм или сериал:
- Radarr — фильмы:
icon: radarr.svg
href: http://192.168.10.25:7878
description: "Очередь: Activity → Queue. Выбрать раздачу вручную: карточка → Interactive Search"
widget:
type: radarr
url: http://radarr:7878
key: "{{HOMEPAGE_VAR_RADARR_KEY}}"
- Sonarr — сериалы:
icon: sonarr.svg
href: http://192.168.10.25:8989
description: "Новые серии отслеживаемых сериалов качает сам. Очередь: Activity → Queue"
widget:
type: sonarr
url: http://sonarr:8989
key: "{{HOMEPAGE_VAR_SONARR_KEY}}"
- Bazarr — субтитры:
icon: bazarr.svg
href: http://192.168.10.25:6767
description: "Субтитры ru/en для всего в библиотеке; чего не хватает — Wanted"
widget:
type: bazarr
url: http://bazarr:6767
key: "{{HOMEPAGE_VAR_BAZARR_KEY}}"
- Загрузки и служебное:
- qBittorrent:
icon: qbittorrent.svg
href: http://192.168.10.25:8080
description: "Сами загрузки: скорость, сиды. Торренты вручную сюда — мимо Jellyfin и контроля места"
widget:
type: qbittorrent
url: http://qbittorrent:8080
username: "{{HOMEPAGE_VAR_QBIT_USER}}"
password: "{{HOMEPAGE_VAR_QBIT_PASS}}"
enableLeechProgress: true
- Prowlarr — трекеры:
icon: prowlarr.svg
href: http://192.168.10.25:9696
description: "RuTracker, NoNaMe Club, TPB, YTS… Ручной поиск по всем трекерам сразу"
widget:
type: prowlarr
url: http://prowlarr:9696
key: "{{HOMEPAGE_VAR_PROWLARR_KEY}}"
- FlareSolverr:
icon: flaresolverr.svg
href: http://192.168.10.25:8191
description: "Проходит проверку Cloudflare для RuTracker (на Pi — минуты). Трогать не нужно"
siteMonitor: http://flaresolverr:8191
+23
View File
@@ -0,0 +1,23 @@
# Homepage — стартовая страница медиацентра, http://192.168.10.25:3000
title: Домашний медиацентр
language: ru
theme: dark
color: slate
headerStyle: clean
hideVersion: true
statusStyle: dot
quicklaunch:
searchDescriptions: true
layout:
Как это работает:
style: row
columns: 4
Смотреть:
style: row
columns: 2
Добавить фильм или сериал:
style: row
columns: 3
Загрузки и служебное:
style: row
columns: 4
+18
View File
@@ -0,0 +1,18 @@
- greeting:
text_size: xl
text: "Логин везде: rus · пароль общий"
- resources:
label: Малина
cpu: true
memory: true
cputemp: true
uptime: true
- resources:
label: Диск медиа (4 ТБ)
disk: /mnt/backup
- datetime:
text_size: l
locale: ru
format:
dateStyle: long
timeStyle: short
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""Первичная связка медиастека на rpi через API. Идемпотентно: повторный запуск
ничего не ломает, уже сделанное пропускает.
Запуск на малине: python3 /opt/media/provision.py [шаг ...]
Шаги: qbit jellyfin arr prowlarr bazarr seerr connect homepage (без аргументов — все).
Пароль админа берётся из /opt/media/.admin-password, API-ключи — из config.xml.
"""
from http.cookiejar import CookieJar
import json, re, subprocess, sys, time, urllib.error, urllib.parse, urllib.request
BASE = "/opt/media"
USER = "rus"
PW = open(f"{BASE}/.admin-password").read().strip()
LANGS = ["ru", "en"] # языки аудио/субтитров по умолчанию
def http(method, url, data=None, headers=None, form=False, opener=None, timeout=60):
body = None
h = dict(headers or {})
if data is not None:
if form:
body = urllib.parse.urlencode(data).encode()
h.setdefault("Content-Type", "application/x-www-form-urlencoded")
else:
body = json.dumps(data).encode()
h.setdefault("Content-Type", "application/json")
req = urllib.request.Request(url, data=body, headers=h, method=method)
try:
r = opener.open(req, timeout=timeout) if opener else urllib.request.urlopen(req, timeout=timeout)
raw = r.read().decode()
try:
return r.status, json.loads(raw) if raw else None
except ValueError:
return r.status, raw
except urllib.error.HTTPError as e:
return e.code, e.read().decode()[:500]
def apikey(app):
return re.search(r"<ApiKey>([^<]+)", open(f"{BASE}/config/{app}/config.xml").read()).group(1)
def log(*a):
print(*a, flush=True)
# ---------------------------------------------------------------- qBittorrent
def qbit():
cj = CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
q = "http://127.0.0.1:8080"
hdr = {"Referer": q}
def ok(st, body): # 5.x отвечает 204 без тела, старые — 200 "Ok."
return body == "Ok." or (st in (200, 204) and any("SID" in c.name for c in cj))
st, body = http("POST", q + "/api/v2/auth/login", {"username": USER, "password": PW}, hdr, form=True, opener=op)
if not ok(st, body): # первый запуск: admin + временный пароль из лога
logs = subprocess.run(["docker", "logs", "qbittorrent"], capture_output=True, text=True).stdout
m = re.findall(r"temporary password is provided for this session: (\S+)", logs)
if not m:
sys.exit("qbit: не могу войти ни с админским, ни с временным паролем")
st, body = http("POST", q + "/api/v2/auth/login", {"username": "admin", "password": m[-1]}, hdr, form=True, opener=op)
assert ok(st, body), (st, body)
prefs = {
"web_ui_username": USER,
"web_ui_password": PW,
"save_path": "/data/torrents/other",
"temp_path_enabled": False,
"auto_tmm_enabled": True, # путь по категории — иначе *arr не найдут файлы
"torrent_changed_tmm_enabled": True,
"category_changed_tmm_enabled": True,
"listen_port": 6881,
"upnp": False,
"max_active_downloads": 3,
"max_active_torrents": 10,
"max_active_uploads": 5,
"preallocate_all": False,
"bypass_local_auth": False,
}
http("POST", q + "/api/v2/app/setPreferences", {"json": json.dumps(prefs)}, hdr, form=True, opener=op)
for cat in ("movies", "tv"):
http("POST", q + "/api/v2/torrents/createCategory", {"category": cat, "savePath": f"/data/torrents/{cat}"}, hdr, form=True, opener=op)
# перелогин уже новым пользователем — проверка
cj.clear()
st, body = http("POST", q + "/api/v2/auth/login", {"username": USER, "password": PW}, hdr, form=True, opener=op)
st, cats = http("GET", q + "/api/v2/torrents/categories", headers=hdr, opener=op)
log("qbit: login", ok(st, body), "categories", sorted(cats) if isinstance(cats, dict) else cats)
# ---------------------------------------------------------------- Jellyfin
JF = "http://127.0.0.1:8096"
JF_AUTH = 'MediaBrowser Client="provision", Device="rpi", DeviceId="provision-rpi", Version="1.0"'
def jf_token():
st, r = http("POST", JF + "/Users/AuthenticateByName", {"Username": USER, "Pw": PW}, {"Authorization": JF_AUTH})
assert st == 200, (st, r)
return r["AccessToken"]
def jellyfin():
st, info = http("GET", JF + "/System/Info/Public")
if not info.get("StartupWizardCompleted"):
h = {"Authorization": JF_AUTH}
http("POST", JF + "/Startup/Configuration",
{"UICulture": "ru", "MetadataCountryCode": "RU", "PreferredMetadataLanguage": "ru"}, h)
http("GET", JF + "/Startup/User", headers=h)
st, r = http("POST", JF + "/Startup/User", {"Name": USER, "Password": PW}, h)
log("jellyfin: user", st)
http("POST", JF + "/Startup/RemoteAccess", {"EnableRemoteAccess": True, "EnableAutomaticPortMapping": False}, h)
st, r = http("POST", JF + "/Startup/Complete", headers=h)
log("jellyfin: wizard complete", st)
tok = jf_token()
h = {"Authorization": f'{JF_AUTH}, Token="{tok}"'}
st, folders = http("GET", JF + "/Library/VirtualFolders", headers=h)
have = {f["Name"] for f in folders}
for name, ctype, path in (("Фильмы", "movies", "/data/library/movies"), ("Сериалы", "tvshows", "/data/library/tv")):
if name in have:
continue
q = urllib.parse.urlencode({"name": name, "collectionType": ctype, "paths": path, "refreshLibrary": "true"})
st, r = http("POST", f"{JF}/Library/VirtualFolders?{q}",
{"LibraryOptions": {"PreferredMetadataLanguage": "ru", "MetadataCountryCode": "RU",
"EnableRealtimeMonitor": True,
"SubtitleDownloadLanguages": [], "SaveSubtitlesWithMedia": True}}, h)
log("jellyfin: library", name, st)
# язык аудио/субтитров по умолчанию у пользователя
st, me = http("GET", JF + "/Users/Me", headers=h)
cfg = me["Configuration"]
cfg.update({"AudioLanguagePreference": "rus", "SubtitleLanguagePreference": "rus",
"PlayDefaultAudioTrack": False, "SubtitleMode": "Smart"})
st, r = http("POST", f"{JF}/Users/{me['Id']}/Configuration", cfg, h)
log("jellyfin: user prefs", st)
# аппаратного транскодинга на Pi 4 нет (V4L2 убран в 10.9) — ограничиваем потоки, чтобы не задушить малину
st, enc = http("GET", JF + "/System/Configuration/encoding", headers=h)
enc.update({"HardwareAccelerationType": "none", "EncodingThreadCount": 3,
"TranscodingTempPath": "/cache/transcodes"})
st, r = http("POST", JF + "/System/Configuration/encoding", enc, h)
log("jellyfin: encoding", st)
# ---------------------------------------------------------------- Sonarr / Radarr
ARR = {
"radarr": {"url": "http://127.0.0.1:7878", "api": "v3", "root": "/data/library/movies", "cat": "movies",
"catfield": "movieCategory", "inner": "http://radarr:7878"},
"sonarr": {"url": "http://127.0.0.1:8989", "api": "v3", "root": "/data/library/tv", "cat": "tv",
"catfield": "tvCategory", "inner": "http://sonarr:8989"},
}
def arr():
for name, a in ARR.items():
k = apikey(name)
h = {"X-Api-Key": k}
u = f"{a['url']}/api/{a['api']}"
# авторизация: формы, пароль всегда («без пароля из LAN» требует AllowedHosts в новых версиях)
st, hc = http("GET", u + "/config/host", headers=h)
if hc.get("authenticationMethod") != "forms":
hc.update({"authenticationMethod": "forms", "authenticationRequired": "enabled",
"username": USER, "password": PW, "passwordConfirmation": PW})
st, r = http("PUT", u + "/config/host", hc, h)
log(name, "auth", st)
st, roots = http("GET", u + "/rootfolder", headers=h)
if not any(r["path"].rstrip("/") == a["root"] for r in roots):
st, r = http("POST", u + "/rootfolder", {"path": a["root"]}, h)
log(name, "rootfolder", st, r if st >= 300 else "")
st, dcs = http("GET", u + "/downloadclient", headers=h)
if not any(d["implementation"] == "QBittorrent" for d in dcs):
st, schema = http("GET", u + "/downloadclient/schema", headers=h)
dc = next(s for s in schema if s["implementation"] == "QBittorrent")
vals = {"host": "qbittorrent", "port": 8080, "username": USER, "password": PW, a["catfield"]: a["cat"]}
for f in dc["fields"]:
if f["name"] in vals:
f["value"] = vals[f["name"]]
dc.update({"name": "qBittorrent", "enable": True, "protocol": "torrent", "priority": 1})
st, r = http("POST", u + "/downloadclient", dc, h)
log(name, "downloadclient", st, r if st >= 300 else "")
# хардлинки вместо копирования при импорте
st, mm = http("GET", u + "/config/mediamanagement", headers=h)
# ⚠️ медиа живут на диске бэкапов: onedrive-backup падает при заполнении >90 % (~370 ГБ свободно).
# 500 ГБ — порог с запасом, ниже которого *arr перестают импортировать.
want = {"copyUsingHardlinks": True, "minimumFreeSpaceWhenImporting": 500_000}
if any(mm.get(k) != v for k, v in want.items()):
mm.update(want)
st, r = http("PUT", u + "/config/mediamanagement", mm, h)
log(name, "mediamanagement", st)
log(name, "ok")
# ---------------------------------------------------------------- Prowlarr
def prowlarr():
k = apikey("prowlarr")
h = {"X-Api-Key": k}
u = "http://127.0.0.1:9696/api/v1"
st, hc = http("GET", u + "/config/host", headers=h)
if hc.get("authenticationMethod") != "forms":
hc.update({"authenticationMethod": "forms", "authenticationRequired": "enabled",
"username": USER, "password": PW, "passwordConfirmation": PW})
st, r = http("PUT", u + "/config/host", hc, h)
log("prowlarr auth", st)
# FlareSolverr как прокси с тегом flaresolverr
st, tags = http("GET", u + "/tag", headers=h)
tag = next((t for t in tags if t["label"] == "flaresolverr"), None)
if not tag:
st, tag = http("POST", u + "/tag", {"label": "flaresolverr"}, h)
st, proxies = http("GET", u + "/indexerProxy", headers=h)
if not any(p["implementation"] == "FlareSolverr" for p in proxies):
st, schema = http("GET", u + "/indexerProxy/schema", headers=h)
p = next(s for s in schema if s["implementation"] == "FlareSolverr")
for f in p["fields"]:
if f["name"] == "host":
f["value"] = "http://flaresolverr:8191/"
if f["name"] == "requestTimeout":
f["value"] = 180 # Chromium на Pi решает челлендж 80–180 с; дефолтных 60 не хватает
p.update({"name": "FlareSolverr", "tags": [tag["id"]]})
st, r = http("POST", u + "/indexerProxy", p, h)
log("prowlarr flaresolverr", st, r if st >= 300 else "")
# приложения: Prowlarr сам раздаёт индексаторы в Sonarr/Radarr
st, apps = http("GET", u + "/applications", headers=h)
have = {a["implementation"] for a in apps}
st, schema = http("GET", u + "/applications/schema", headers=h)
for name, impl in (("radarr", "Radarr"), ("sonarr", "Sonarr")):
if impl in have:
continue
a = next(s for s in schema if s["implementation"] == impl)
vals = {"prowlarrUrl": "http://prowlarr:9696", "baseUrl": ARR[name]["inner"], "apiKey": apikey(name)}
for f in a["fields"]:
if f["name"] in vals:
f["value"] = vals[f["name"]]
a.update({"name": impl, "syncLevel": "fullSync"})
st, r = http("POST", u + "/applications", a, h)
log("prowlarr app", impl, st, r if st >= 300 else "")
# публичные индексаторы без регистрации; приватные (RuTracker, Kinozal) — руками, нужен аккаунт
st, idx = http("GET", u + "/indexer", headers=h)
have = {i["definitionName"] for i in idx}
st, schema = http("GET", u + "/indexer/schema", headers=h)
by = {s["definitionName"]: s for s in schema}
for d in ("rutor", "noname-club", "1337x", "thepiratebay", "eztv", "yts", "kinozal"):
if d in have or d not in by:
if d not in by:
log("prowlarr indexer", d, "нет в схеме")
continue
s = by[d]
if s.get("privacy") != "public":
continue
s.update({"name": s["name"], "enable": True, "appProfileId": 1, "priority": 25})
if d == "1337x":
s["tags"] = [tag["id"]] # за Cloudflare
try: # Prowlarr тестирует индексатор при добавлении; через FlareSolverr это долго
st, r = http("POST", u + "/indexer", s, h, timeout=240)
except OSError as e:
log("prowlarr indexer", d, "не добавлен:", e)
continue
log("prowlarr indexer", d, st, (r if isinstance(r, str) else "")[:200] if st >= 300 else "")
http("POST", u + "/command", {"name": "ApplicationIndexerSync"}, h)
log("prowlarr ok")
# ---------------------------------------------------------------- Homepage + Jellyfin connect
def jf_apikey(name):
"""API-ключ Jellyfin для приложения name (создаётся при отсутствии)."""
tok = jf_token()
h = {"Authorization": f'{JF_AUTH}, Token="{tok}"'}
find = lambda: next((x["AccessToken"] for x in http("GET", JF + "/Auth/Keys", headers=h)[1]["Items"]
if x["AppName"] == name), None)
if not find():
http("POST", f"{JF}/Auth/Keys?app={name}", headers=h)
return find()
def homepage():
"""/opt/media/homepage.env (rus 600) — ключи для виджетов, в репо их нет."""
import os, yaml
env = {
"HOMEPAGE_VAR_JELLYFIN_KEY": jf_apikey("homepage"),
"HOMEPAGE_VAR_SEERR_KEY": json.load(open(f"{BASE}/config/seerr/settings.json"))["main"]["apiKey"],
"HOMEPAGE_VAR_RADARR_KEY": apikey("radarr"),
"HOMEPAGE_VAR_SONARR_KEY": apikey("sonarr"),
"HOMEPAGE_VAR_PROWLARR_KEY": apikey("prowlarr"),
"HOMEPAGE_VAR_BAZARR_KEY": yaml.safe_load(open(f"{BASE}/config/bazarr/config/config.yaml"))["auth"]["apikey"],
"HOMEPAGE_VAR_QBIT_USER": USER,
"HOMEPAGE_VAR_QBIT_PASS": PW,
}
old = os.umask(0o077)
open(f"{BASE}/homepage.env", "w").write("".join(f"{k}={v}\n" for k, v in env.items()))
os.umask(old)
log("homepage env written (перезапусти контейнер homepage, если ключи сменились)")
def connect():
"""Radarr/Sonarr → Jellyfin: обновить библиотеку сразу после импорта (иначе ждать скан по расписанию)."""
k = jf_apikey("arr-connect")
for name, a in ARR.items():
h = {"X-Api-Key": apikey(name)}
u = f"{a['url']}/api/{a['api']}"
st, ns = http("GET", u + "/notification", headers=h)
if any(n["implementation"] == "MediaBrowser" for n in ns):
continue
st, schema = http("GET", u + "/notification/schema", headers=h)
n = next(x for x in schema if x["implementation"] == "MediaBrowser")
vals = {"host": "192.168.10.25", "port": 8096, "useSsl": False, "apiKey": k, "notify": False, "updateLibrary": True}
for f in n["fields"]:
if f["name"] in vals:
f["value"] = vals[f["name"]]
n.update({"name": "Jellyfin", "onDownload": True, "onUpgrade": True, "onRename": True,
"onMovieDelete": True, "onMovieFileDelete": True, "onSeriesDelete": True, "onEpisodeFileDelete": True})
st, r = http("POST", u + "/notification", n, h)
log(name, "jellyfin connect", st, r if st >= 300 else "")
# ---------------------------------------------------------------- Bazarr
def bazarr():
import yaml # python3-yaml есть в Raspberry Pi OS
cfg = yaml.safe_load(open(f"{BASE}/config/bazarr/config/config.yaml"))
h = {"X-API-KEY": cfg["auth"]["apikey"]}
u = "http://127.0.0.1:6767/api"
profile = {"profileId": 1, "name": "ru+en", "cutoff": None, "mustContain": [], "mustNotContain": [],
"originalFormat": False, "tag": None,
"items": [{"id": i + 1, "language": l, "forced": "False", "hi": "False", "audio_exclude": "False",
"audio_only_include": "False"} for i, l in enumerate(LANGS)]}
form = [
("languages-enabled", json.dumps(LANGS)),
("languages-profiles", json.dumps([profile])),
("settings-general-serie_default_enabled", "true"),
("settings-general-serie_default_profile", "1"),
("settings-general-movie_default_enabled", "true"),
("settings-general-movie_default_profile", "1"),
# без аккаунтов; OpenSubtitles.com (лучший для ru) требует логин — добавить руками
("settings-general-enabled_providers", json.dumps(["embeddedsubtitles", "podnapisi", "yifysubtitles"])),
("settings-general-use_sonarr", "true"),
("settings-general-use_radarr", "true"),
("settings-sonarr-ip", "sonarr"), ("settings-sonarr-port", "8989"), ("settings-sonarr-apikey", apikey("sonarr")),
("settings-radarr-ip", "radarr"), ("settings-radarr-port", "7878"), ("settings-radarr-apikey", apikey("radarr")),
("settings-auth-type", "form"), ("settings-auth-username", USER), ("settings-auth-password", PW),
]
body = urllib.parse.urlencode(form).encode()
req = urllib.request.Request(u + "/system/settings", data=body, headers=h, method="POST")
try:
st = urllib.request.urlopen(req, timeout=60).status
except urllib.error.HTTPError as e:
st = (e.code, e.read().decode()[:300])
log("bazarr settings", st)
st, prof = http("GET", u + "/system/languages/profiles", headers=h)
log("bazarr profiles", [p["name"] for p in prof] if isinstance(prof, list) else prof)
# ---------------------------------------------------------------- Jellyseerr
def jellyseerr():
js = "http://127.0.0.1:5055/api/v1"
cj = CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
st, pub = http("GET", js + "/settings/public", opener=op)
if not pub.get("initialized"):
st, r = http("POST", js + "/auth/jellyfin",
{"username": USER, "password": PW, "hostname": "192.168.10.25", "port": 8096,
"useSsl": False, "urlBase": "", "email": "rus@localhost", "serverType": 2}, opener=op)
log("seerr: jellyfin login", st, r if st >= 300 else "")
st, libs = http("GET", js + "/settings/jellyfin/library?sync=true", opener=op)
ids = ",".join(l["id"] for l in libs) if isinstance(libs, list) else ""
http("GET", js + f"/settings/jellyfin/library?enable={ids}", opener=op)
st, r = http("POST", js + "/settings/initialize", {}, opener=op)
log("seerr: initialize", st)
else:
st, r = http("POST", js + "/auth/jellyfin", {"username": USER, "password": PW}, opener=op)
log("seerr: login", st)
for name, path, port in (("radarr", "/settings/radarr", 7878), ("sonarr", "/settings/sonarr", 8989)):
st, cur = http("GET", js + path, opener=op)
if cur:
continue
k = apikey(name)
base = f"http://127.0.0.1:{port}/api/v3"
st, profiles = http("GET", base + "/qualityprofile", headers={"X-Api-Key": k})
prof = next((p for p in profiles if p["name"] in ("HD-1080p", "HD - 720p/1080p")), profiles[0])
body = {"name": name.capitalize(), "hostname": name, "port": port, "apiKey": k, "useSsl": False,
"baseUrl": "", "activeProfileId": prof["id"], "activeProfileName": prof["name"],
"activeDirectory": ARR[name]["root"], "is4k": False, "isDefault": True,
"externalUrl": "", "syncEnabled": True, "preventSearch": False}
if name == "radarr":
body["minimumAvailability"] = "released"
else:
body.update({"activeAnimeProfileId": prof["id"], "activeAnimeDirectory": ARR[name]["root"],
"enableSeasonFolders": True})
st, r = http("POST", js + path, body, opener=op)
log("seerr:", name, st, r if st >= 300 else "")
log("seerr ok")
STEPS = {"qbit": qbit, "jellyfin": jellyfin, "arr": arr, "prowlarr": prowlarr, "bazarr": bazarr, "connect": connect, "homepage": homepage, "seerr": jellyseerr}
if __name__ == "__main__":
for s in sys.argv[1:] or list(STEPS):
log(f"=== {s}")
STEPS[s]()