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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
880ad61253
commit
08aa787b95
@@ -5,3 +5,4 @@ secrets/
|
||||
alertmanager/telegram_token
|
||||
**/telegram_token
|
||||
stacks/ipcam/.env
|
||||
__pycache__/
|
||||
|
||||
@@ -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) — `stacks/rpi-media/`.
|
||||
|
||||
## Поддержание описаний в актуальном состоянии
|
||||
Тронул сервер — обнови `servers/<хост>.md` в том же прогоне. Контракт и
|
||||
|
||||
+35
-4
@@ -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, запись `DHCPStaticAddress.2`; была без резерва).
|
||||
|
||||
## Доступ
|
||||
- С 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.
|
||||
@@ -77,6 +81,32 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg
|
||||
|
||||
- Для перевёрстки извлечённого текста в 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 |
|
||||
|
||||
- **Данные — `/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, Kinozal) — руками, нужен аккаунт.
|
||||
- Bazarr: провайдеры без аккаунтов (`embeddedsubtitles`, `podnapisi`, `yifysubtitles`); для русских субтитров лучший — OpenSubtitles.com, нужен аккаунт.
|
||||
- **Авторизация:** везде пользователь `rus`, один пароль — `/opt/media/.admin-password` (rus 600). *arr — формы, пароль **всегда** (режим «без пароля из LAN» в новых *arr требует `AllowedHosts`). Между собой сервисы ходят по API-ключам из `config/<app>/config.xml`.
|
||||
- Первичная связка — `stacks/rpi-media/provision.py` (идемпотентный, пароль и ключи читает на месте): `python3 /opt/media/provision.py [qbit jellyfin arr prowlarr bazarr seerr]`.
|
||||
- ⚠️ Каталог `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`).
|
||||
@@ -109,7 +139,7 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg
|
||||
- Свободная беседа, интернет-поиск, объяснение школьного материала, поиск и генерация картинок для Поли разрешены 2026-10-11; правила установлены в ~/.codex/AGENTS.md, источник [astra-bot `95a2867`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/95a2867). Содержание подбирается для 10 лет, материалы 16+/18+ исключаются; выбранные картинки проверяются перед отправкой. Это инструкции агента, не отдельный сетевой фильтр. Разрешение не распространяется на управление сервером и доступами.
|
||||
|
||||
## Открытые хвосты (на 2026-10-11)
|
||||
- ⏳ 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 (решение за пользователем).
|
||||
@@ -120,3 +150,4 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg
|
||||
- Через неделю снять SMART и посчитать реальный прирост Start_Stop/Load_Cycle — прогноз ресурса пока оценочный.
|
||||
|
||||
- ⏳ Определить хранение входящих фото: сейчас автоматически не удаляются ради ссылок в старых тредах; очередь сообщений только в памяти и теряется при перезапуске.
|
||||
- Медиацентр: проброс 6881 tcp/udp на роутере (раздача/скорость); пароль SFTP роутера слабый (только LAN) — сменить; приватные трекеры и OpenSubtitles — аккаунты пользователя; решить про автообновление образов.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Медиацентр на 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"
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Первичная связка медиастека на rpi через API. Идемпотентно: повторный запуск
|
||||
ничего не ломает, уже сделанное пропускает.
|
||||
|
||||
Запуск на малине: python3 /opt/media/provision.py [шаг ...]
|
||||
Шаги: qbit jellyfin arr prowlarr bazarr seerr (без аргументов — все).
|
||||
Пароль админа берётся из /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/"
|
||||
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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 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, "seerr": jellyseerr}
|
||||
|
||||
if __name__ == "__main__":
|
||||
for s in sys.argv[1:] or list(STEPS):
|
||||
log(f"=== {s}")
|
||||
STEPS[s]()
|
||||
Reference in New Issue
Block a user