From 176960835582bca39639cf5d55e455ac5029015e Mon Sep 17 00:00:00 2001 From: gilfanovr Date: Sun, 11 Oct 2026 15:31:46 +0300 Subject: [PATCH] =?UTF-8?q?rpi:=20=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=80=20(Jellyfin,=20Seerr,=20qBittorrent,?= =?UTF-8?q?=20*arr)=20=D0=B2=20Docker;=20=D0=B4=D0=B8=D1=81=D0=BA=20=D1=80?= =?UTF-8?q?=D0=BE=D1=83=D1=82=D0=B5=D1=80=D0=B0=20=D0=B8=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B5=D1=80=D0=B2=20DHCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stacks/rpi-media: docker-compose и идемпотентный provision.py - servers/rpi.md: раздел «Медиацентр», почему NTFS и SFTP на роутере, закрыт хвост про резерв адреса - AGENTS.md: роль малины Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- servers/rpi.md | 40 ++- .../__pycache__/provision.cpython-314.pyc | Bin 0 -> 26497 bytes stacks/rpi-media/docker-compose.yml | 112 ++++++ stacks/rpi-media/provision.py | 339 ++++++++++++++++++ 5 files changed, 487 insertions(+), 6 deletions(-) create mode 100644 stacks/rpi-media/__pycache__/provision.cpython-314.pyc create mode 100644 stacks/rpi-media/docker-compose.yml create mode 100644 stacks/rpi-media/provision.py diff --git a/AGENTS.md b/AGENTS.md index 12ec74f..199f998 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` в том же прогоне. Контракт и diff --git a/servers/rpi.md b/servers/rpi.md index 8be5b4d..3e68d30 100644 --- a/servers/rpi.md +++ b/servers/rpi.md @@ -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//{daily,weekly,monthly}` (root 700): 14 / 8 / 12, monthly — хардлинк первого daily месяца. fr1 daily ~2.1 ГБ (5.5 мин), ru1 ~17 МБ, fr1 weekly (вс) — тома ботов и imgbot. @@ -72,6 +76,32 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg - ⚠️ 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, очередь пуста; качество на бумаге проверяет пользователь. +## Медиацентр (с 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//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`). @@ -88,8 +118,7 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg - **Доступ к 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) `, host key Gitea (RSA) закреплён. Автопулла нет. ## Открытые хвосты (на 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 +129,4 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg - Через неделю снять SMART и посчитать реальный прирост Start_Stop/Load_Cycle — прогноз ресурса пока оценочный. - ⏳ Определить хранение входящих фото: сейчас автоматически не удаляются ради ссылок в старых тредах; очередь сообщений только в памяти и теряется при перезапуске. +- Медиацентр: проброс 6881 tcp/udp на роутере (раздача/скорость); пароль SFTP роутера слабый (только LAN) — сменить; приватные трекеры и OpenSubtitles — аккаунты пользователя; решить про автообновление образов. diff --git a/stacks/rpi-media/__pycache__/provision.cpython-314.pyc b/stacks/rpi-media/__pycache__/provision.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e6954468d851d041472ef71ca61562e22f4130aa GIT binary patch literal 26497 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 literal 0 HcmV?d00001 diff --git a/stacks/rpi-media/docker-compose.yml b/stacks/rpi-media/docker-compose.yml new file mode 100644 index 0000000..1b8669f --- /dev/null +++ b/stacks/rpi-media/docker-compose.yml @@ -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" diff --git a/stacks/rpi-media/provision.py b/stacks/rpi-media/provision.py new file mode 100644 index 0000000..3044dbc --- /dev/null +++ b/stacks/rpi-media/provision.py @@ -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"([^<]+)", 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]()