#!/usr/bin/env bash # install.sh — установка torrcast на Debian/Ubuntu (в том числе в LXC). Идемпотентен: # повторный запуск ничего не ломает и не пересоздаёт то, что уже на месте. # # Фазы: локаль → зависимости → (ffmpeg, TorrServer, Prowlarr, источники - РАЗОМ, в фоне) # → пакет → индексеры → конфиг → раздача → приёмник. # Порядок фаз в выводе - это порядок, в котором результат каждой впервые понадобился, а # не порядок их запуска: всё, что про сеть, идёт параллельно (см. `job_start`). # Что первому показу не нужно (индексер на сто секунд, оценки IMDb к меню), доводится # уже после «готово» (см. `late_run`) - и об этом сказано строкой, а не молчанием. # Ноль регистраций и внешних ключей: apikey Prowlarr генерит сам себе, мы его # вычитываем из его же config.xml и кладём в конфиг torrcast. И ручного шага после # «готово» нет: приёмник подхватывает сама установка (см. `setup_receiver`) - # единственный найденный в сети берётся без вопроса, а несколько она только # перечислит: выбор останется явной команде человека. # # Песочница (проверка фаз без root и без прода): # TORRCAST_PREFIX=/tmp/t TORRCAST_CONFIG_DIR=/tmp/t/etc TORRCAST_STATE_DIR=/tmp/t/var \ # TORRCAST_BIN_DIR=/tmp/t/bin TORRCAST_NO_ROOT=1 TORRCAST_NO_SYSTEMD=1 \ # TORRCAST_PHASES="torrserver prowlarr indexers config" ./install.sh set -euo pipefail #: TC-955. Язык - выбор человека (cast --ru / --en, TC-926), а не замер кода, поэтому #: установщик различает «назван руками» и «молчание». TORRCAST_LANGUAGE здесь - не #: переменная продукта (оттуда она ушла в TC-926), а канал однострока: `install` зовёт #: install.sh без ключей и потому сносит названный язык окружением через перезапуск #: под sudo. Пустое значение значит «в этот заход язык не называли», и повторная #: установка обязана оставить выбор из живого конфига (см. ниже, после CONFIG_DIR). LANGUAGE="${TORRCAST_LANGUAGE:-}" #: Голое число первым доводом - ХОЛОСТАЯ подача заставки на столько секунд (см. точку #: входа в самом низу). Разбирается ЗДЕСЬ, вместе с ключами, а не в точке входа: разбор #: ключей исполняется всегда и первым, и до правки он отбивал число как незнакомый ключ #: кодом 2. Холостой подачи из-за этого не было вовсе, а её ветка внизу была недостижима. UI_DRY_SECONDS='' while (( $# )); do case "$1" in -en) LANGUAGE=en ;; -ru) LANGUAGE=ru ;; *) if [ -n "$1" ] && [ -z "${1//[0-9]/}" ]; then UI_DRY_SECONDS=$1 else printf 'error: unknown option: %s\n' "$1" >&2 printf 'usage: %s [-en|-ru]\n' "$0" >&2 exit 2 fi ;; esac shift done case "$LANGUAGE" in en|ru|"") ;; *) printf 'error: TORRCAST_LANGUAGE must be en or ru\n' >&2; exit 2 ;; esac # Отказываем чужой ОС до чтения конфигов, временных файлов и поднятия прав. Дальше # ветки именуются этой один раз измеренной платформой: подделка uname тем самым # проверяет ровно входной барьер, а не случайный вызов глубоко в установке. case "$(uname -s 2>/dev/null || true)" in Linux) OS_FAMILY=linux ;; Darwin) OS_FAMILY=macos ;; *) if [ "$LANGUAGE" = ru ]; then printf 'ошибка: нужен Debian/Ubuntu или macOS\n' >&2 else printf 'error: Debian/Ubuntu or macOS is required\n' >&2 fi exit 2 ;; esac MACHINE_ARCH="$(uname -m 2>/dev/null || true)" #: Названный в этот заход язык - единственный, которым повторная установка вправе #: переписать язык живого конфига (см. setup_config). LANGUAGE_NAMED="$LANGUAGE" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" #: Собственный путь абсолютом - им же установщик перезапускает себя под sudo #: (:func:`become_root`). Относительный `$0` пережил бы не всякий перезапуск, а #: `basename` тут звать нечем: до перезапуска PATH человека нам не обещан. SELF="$REPO_DIR/${BASH_SOURCE[0]##*/}" PREFIX="${TORRCAST_PREFIX:-/opt/torrcast}" CONFIG_DIR="${TORRCAST_CONFIG_DIR:-/etc/torrcast}" STATE_DIR="${TORRCAST_STATE_DIR:-/var/lib/torrcast}" BIN_DIR="${TORRCAST_BIN_DIR:-/usr/local/bin}" #: Чем поднимаются права (:func:`become_root`). Переопределение - для стенда, и оно там #: обязательно: настоящий sudo в проверке поднятия участвовать не может, он запустил бы #: настоящую установку на машине, которая её не просила. SUDO="${TORRCAST_SUDO:-sudo}" #: Куда кладётся правило sudoers, открывающее обычному пользователю мака запуск cast #: под root (:func:`setup_cast_sudoers`), и чем оно проверяется до записи. Оба #: переопределения - для песочницы тестов: настоящие /etc/sudoers.d и visudo в ней #: участвовать не могут, это чужая машина. SUDOERS_D="${TORRCAST_SUDOERS_D:-/etc/sudoers.d}" VISUDO="${TORRCAST_VISUDO:-visudo}" #: 🔴 TC-887. Версия, от которой идёт обновление; ставит её загрузчик (`install`), когда #: его позвал `cast --upgrade`. Установщик от этого не меняет НИ ОДНОГО своего действия - #: он остаётся идемпотентным, - меняются только слова: итоговое слово, шапка заставки и #: последний экран. Пусто либо равно своей же версии - обычная установка. UPGRADE_FROM="${TORRCAST_UPGRADE_FROM:-}" #: Молчание при повторной установке берёт язык из живого конфига - и говорит на нём же. #: jq тут ещё не гарантирован (его ставит фаза пакетов), поэтому читаем sed'ом строго #: en|ru: чужое содержимое поля означает ровно «выбора нет», и ответ - дефолт. #: 🔴 Спрашиваем ЧИТАЕМОСТЬ, а не наличие. Конфиг лежит под root'ом, и до поднятия прав #: (:func:`become_root`) читателя может не хватить: `[ -f ]` на такой файл отвечает #: «да», sed падает «Permission denied», а `set -e` роняет установку ещё до первого её #: слова. Куплено живым прогоном на машине с уже поставленным продуктом: обычный #: пользователь получал отказ прав ВМЕСТО перезапуска под sudo. Не прочли - молчим, как #: и договаривались: язык возьмёт своё умолчание, а перезапущенный под root заход #: прочтёт живой конфиг и заговорит на его языке. if [ -z "$LANGUAGE" ] && [ -r "$CONFIG_DIR/config.json" ]; then LANGUAGE="$(sed -n 's/.*"language" *: *"\(en\|ru\)".*/\1/p' "$CONFIG_DIR/config.json" | head -n 1)" fi [ -n "$LANGUAGE" ] || LANGUAGE=en #: Интерпретатор ищем, а не прибиваем. Нижняя граница - 3.12 (requires-python), и на #: Debian 12 её нет вовсе: в репозиториях у неё только python3.11. Такую машину #: установщик не бросает и чужих apt-репозиториев ей не дописывает - интерпретатор #: приносит uv, рядом с продуктом (:func:`pick_python`). PYTHON="${TORRCAST_PYTHON:-}" #: Куда ложатся сам uv и принесённый им интерпретатор. Место продуктовое, а не #: домашнее: $HOME у перезапуска под sudo чужой и разный, а повторная установка и #: `cast --upgrade` обязаны найти уже принесённое и не качать его заново. UV_DIR="${TORRCAST_UV_DIR:-$PREFIX/uv}" UV_PYTHON_DIR="${TORRCAST_UV_PYTHON_DIR:-$PREFIX/python}" #: Официальный установщик uv. Переопределение - для стендовой проверки шва, живой #: адрес меняется только вслед за astral.sh. UV_INSTALL_URL="${TORRCAST_UV_URL:-https://astral.sh/uv/install.sh}" #: Индекс пакетов Python. В части сетей штатный индекс не отвечает вовсе, поэтому #: рядом лежат полные зеркала (индекс + файлы) — установка сама выберет живое. #: Зеркала намеренно из разных геозон: первое вне китайского сегмента, два других #: китайские - блокировка одной зоны не убивает разом все резервы. Ручной выбор - #: через TORRCAST_PIP_INDEX. PIP_INDEX="${TORRCAST_PIP_INDEX:-https://pypi.org/simple}" PIP_MIRRORS=("https://pypi-mirror.gitverse.ru/simple" "https://pypi.tuna.tsinghua.edu.cn/simple" "https://mirrors.aliyun.com/pypi/simple") TS_HOST="${TORRCAST_TS_HOST:-127.0.0.1}" TS_PORT="${TORRCAST_TS_PORT:-8090}" PL_HOST="${TORRCAST_PL_HOST:-127.0.0.1}" PL_PORT="${TORRCAST_PL_PORT:-9696}" TS_URL="http://$TS_HOST:$TS_PORT" PL_URL="http://$PL_HOST:$PL_PORT" #: 🔴 TC-311. Prowlarr ходит к трекерам строго по IPv4, и это замер, а не осторожность. #: Один и тот же ответ в одно и то же мгновение: по IPv6 тело обрывается РАНЬШЕ, чем по #: IPv4 - у одного имени 13.4-13.9 КБ против 17.5-18.9 КБ, у другого 15.0-16.4 КБ против #: 20.5 КБ, шесть попыток из шести. Дорогу выбирает не наш код, а тот, кто по ней идёт, и #: по умолчанию он берёт IPv6: снимок соединений во время живого поиска - Prowlarr сидит #: на IPv6-адресе трекера все 25 секунд и получает «Failed to read complete http response». #: Отсюда и старая загадка «шим лечит имя»: во многом он лечил его тем, что сам ходит #: только по IPv4. #: #: ⚠️ Порядок семейств в `/etc/gai.conf` тут НЕ помогает, и это тоже замер: со строкой #: `precedence ::ffff:0:0/96 100` система стала называть IPv4 первым (`getent ahosts`), а #: Prowlarr в тот же миг всё равно ушёл на IPv6. Его дорогу двигает только своя ручка - #: та, что ниже; с ней в том же замере он пошёл на IPv4-адрес трекера. #: #: Цена ошибки несимметрична: имя, здоровое по IPv4 и битое по IPv6, отвечает пробе #: «здоров», а индексер молчит - причём молчит не словами, а пустой выдачей. PL_IPV4="Environment=DOTNET_SYSTEM_NET_DISABLEIPV6=1" #: Кэш раздачи - это ЗАПАС ПОКАЗА НА ОБРЫВ: пока в нём лежат куски вперёд от места #: показа, картинка живёт без интернета. Поэтому «сколько его» - вопрос не вкуса, а #: обещания продукта, и размер НЕ прибит числом: он считается от машины #: (:func:`ts_cache_place`), потому что машина бывает и на 4 ГБ, и на 32. Ручное #: переопределение - TORRCAST_TS_CACHE (байты), место - TORRCAST_TS_CACHE_DIR. #: #: Кэш умеет лежать в двух местах, и выбирается ТО, ГДЕ ЕГО ПОМЕСТИТСЯ БОЛЬШЕ #: (:func:`ts_cache_place`): в памяти он упирается в память машины, на диске - в диск. #: #: ⚠️ Замер (один и тот же фильм 12.3 Мбит/с, две головы чтения - показ и прогрев): #: #: * 3.2 ГиБ В ПАМЯТИ: запас 32.2 минуты показа, RSS службы 5.0 ГиБ, свободной памяти #: у машины осталось 2.7 ГиБ из 8; #: * 3.2 ГиБ НА ДИСКЕ: запас те же 32.2 минуты, RSS службы 104 МиБ, свободной памяти #: 7.7 ГиБ. Скорость чтения из кэша под двумя головами - 0.7-0.9 ГБ/с (из памяти #: 1.4 ГБ/с), при нужде показа с прогревом 8 МБ/с это не узкое место; #: * 12 ГиБ НА ДИСКЕ (в памяти столько на 8-гигабайтной машине не бывает): запас #: 81 минута, RSS службы 111 МиБ. #: #: То есть на диске запас перестаёт конкурировать с памятью машины: он стоит ровно #: своего места на диске, а служба весит около сотни мегабайт при любом размере кэша. #: Кэш вытесняется по размеру и переживает перезапуск службы, в том числе аварийный: #: прогретое продолжает кормить показ, а не пропадает вместе со службой. TS_CACHE_DIR="${TORRCAST_TS_CACHE_DIR:-$PREFIX/torrserver/cache}" #: Во сколько раз RSS службы больше кэша, который она держит В ПАМЯТИ. Замер: кэш лежит #: в куче Go, и рядом с каждым куском живёт его копия в работе (readahead) плюс мусор, #: который сборщик забирает уже после. Прибитые 4 ГиБ «половина от 8 ГиБ памяти» #: половиной не были: 4 ГиБ кэша - 7.45 ГБ RSS при потолке 8 ГБ, и машина вставала колом #: на четвёртой минуте показа. Отсюда делитель. TS_MEM_OVERHEAD=2 #: Насколько сборщику Go позволено растить кучу СВЕРХ живого кэша, процентов. #: #: Делитель выше долго был пожеланием: его вычитали из памяти машины, а самой службе #: не говорили ничего, и Go по умолчанию (GOGC=100) удваивал кучу над живым кэшем - #: ровно тот двукратный перерасход, под который делитель и заводился. Замер на стенде #: 06-09-2026, один и тот же прогрев одной серии, кэш в памяти 3.13 ГиБ, потолок #: контейнера 8196 МиБ: без ручки RSS службы 5911 МиБ и контейнер 8131 МиБ (99.2 % #: потолка, 876 событий реклейма); с GOGC=25 - 4039 МиБ и 7367 МиБ (89.9 %, реклейма #: ноль). Прогретое при этом совпало кусок в кусок: 119 кусков и один и тот же список. #: 25 - это перерасход 1.25 при делителе 2, то есть запас ещё вдвое; GOMEMLIMIT ниже #: не опускается и остаётся аварийным упором, а не рабочим режимом. TS_GOGC=25 #: МиБ, которые НЕ отдаём под кэш В ПАМЯТИ: сама система, python показа, два ffmpeg, #: сегменты в /dev/shm. Замер под показом с прогревом: 0.5-1.2 ГиБ, берём с запасом. TS_MEM_RESERVE=1792 #: МиБ памяти службе, когда кэш лежит НА ДИСКЕ. Замер: 104 МиБ при кэше 3.2 ГиБ и 111 МиБ #: при 12 ГиБ, то есть от размера кэша это число не зависит вовсе; запас - вчетверо. TS_DISK_MEM=512 #: Байты раздела, которые не трогаем ни при каких обстоятельствах: тут живут состояние и #: сама система. Это только неприкосновенный запас; полный резерв кэша складывает #: `ts_cache_disk` - к нему добавляется бюджет прогрева, вычитанный из #: ``torrcast.domain.warm_settings.WARM_BUDGET`` (то же слагаемое берёт #: ``torrcast.usecases.doctor``: #: ``CACHE_DISK_RESERVE``). Кэш обязан помещаться СВЕРХ них, иначе прогрев и кэш будут #: выедать друг друга и обещание «показ переживает обрыв» сломают оба сразу. TS_DISK_FLOOR=$((3 * 1024 * 1024 * 1024)) #: Нижняя и верхняя границы кэша. Нижняя - чтобы на самой тесной машине осталось чем #: кормить показ; верхняя - потому что дальше расти незачем: запас на обрыв уже часовой. TS_CACHE_MIN=268435456 TS_CACHE_MAX=8589934592 # --- Версии соседей: пины ---------------------------------------------------- # TorrServer и Prowlarr ставятся не «последние какие есть», а те, на которых обвязка # API проверена живьём. Свежий мажор соседа может молча сменить формат ответа - и # сломается это уже у чужого человека на чистой машине, а не у нас. # # Как поднять версию: поменять тег в нужной строке ниже - и прогнать install.sh на # чистой машине. Правка одна, оба пина лежат тут; самопроверка в конце установки # скажет, годится ли новая версия. # Разовое переопределение: TORRCAST_TS_VERSION=… TORRCAST_PL_VERSION=… ./install.sh # # Теги пишутся ровно так, как они названы у авторов: у TorrServer без «v», у # Prowlarr с «v». # # У TorrServer пин - ТОЛЬКО базовый тег вида MatriX.NNN. Точечные (MatriX.NNN.M) там # живут до следующего релиза и пропадают: на 22-08-2026 в репозитории 85 релизов с 2018 # года, из них 61 MatriX - и ни одного точечного, а базовые идут подряд 81..143 (нет # трёх). Прошлый пин был точечным, сгинул с места загрузки - и установки молча разъехались # по разным версиям тракта показа. TS_VERSION="${TORRCAST_TS_VERSION:-MatriX.143}" PL_VERSION="${TORRCAST_PL_VERSION:-v2.5.2.5491}" # Индексеры: definitionName в схеме Prowlarr + базовый URL. Только открытые: ни # регистрации, ни капчи, ни ключа - трекеры с логином здесь не появятся принципиально. # Knaben - метапоиск (агрегирует чужие каталоги и отдаёт infoHash), остальные прямые. # Недоступный из этой сети индексер просто не добавится и работать не помешает. INDEXERS=( "Knaben|https://knaben.org/" # метапоиск: широкий хвост каталога "rutor|https://rutor.info/" # русские раздачи и озвучки "nyaasi|https://nyaa.si/" # аниме "sukebeinyaasi|https://sukebei.nyaa.si/" # ещё один открытый каталог, тот же движок, что у nyaa.si "anilibria|http://localhost:9697/" # аниме с русской озвучкой "jacred|http://127.0.0.1:9698/" # сменный открытый каталог русских озвучек # У YTS адрес API отдельной настройкой, и умолчание там - имя, которое в этой сети # угоняет DNS (чужой адрес с самоподписанным сертом). Живое имя одно, оба его адреса # отвечают 200 за 0.9-1.4 с, но тело рвётся на объёме - см. строку в SHIMS. "yts|https://yts.gg/|apiurl=yts.gg" ) # Кто из них держит установку, списком не решается: решает роль (:func:`late_indexer`). # Prowlarr сопровождает добавление пробным обращением к трекеру и ждёт его до своего # таймаута - замер: 100.06 с на одном yts, и `forceSave=true` его не отключает (проверено # живьём). Замер 06-09-2026 на чистом госте: пять источников на глазах у человека стоили # 63.7 с из 117.8 с всей установки, и 38.5 с из них - один sukebei.nyaa.si, который # ответил отказом и каталога собой не урезал. Поэтому на критическом пути остаётся первый # носитель роли, и только он: его непроход обязан быть виден словами в самой установке, а # не найден потом в журнале. # На этом индексере держится примерно половина каталога - весь западный хвост и аниме: # прямые трекеры из списка его не перекрывают, замены среди метапоисков в открытом пуле # нет. Поиск без него продолжает работать (деградируем, а не умираем), но выдача # заметно беднее - поэтому его непроход обязан быть виден словами, а не строкой в общем # потоке. То же состояние показывает `cast doctor`. KEY_INDEXER="Knaben" # ⚠️ Он же - весь хвост поиска: api.knaben.org отдаёт 502 через 10-15 с (холодный # бэкенд), и один поиск из семи стоит 10-20 с. Сверху Prowlarr повторяет запрос сам, с # отсрочкой - в его логе это «Request for Knaben failed with status BadGateway. Retrying # in 0.26-4.19 s». Настройкой этот повтор НЕ снимается и не укорачивается: строка зашита # в Prowlarr.Core.dll, в /api/v1/config/* её нет (эндпоинта config/indexer у Prowlarr # вовсе нет), в полях самого индексера - тоже (baseSettings.* это лимиты запросов, # torrentBaseSettings.* - сиды и время раздачи). Проверено на 2.5.2.5491. Поэтому # единственная ручка хвоста - наша: личный бюджет индексера # (torrcast/domain/indexer_budget.py, QUORUM_TIMEOUT), и там же записано, почему Knaben # ждут дольше остальных. # 🔴 TC-692/TC-705. Каталог держится не на именах, а на РОЛЯХ: слева роль словами (её и # прочтёт человек), справа - её носители в том порядке, в котором их спрашивают. Замер по # журналу запросов: без метапоиска пул пуст у 72 запросов из 93, без него и без русского # источника - у 97 из 99; остальные индексеры узкие (аниме) и этой дыры не закрывают. # Роль закрыта, если ОТВЕТИЛ хотя бы один её носитель. У русских раздач их два, и они # заменяют друг друга: замер одним и тем же запросом на живой машине - RuTor отдаёт 100 # раздач, JacRed 75, и там и там дорожки с русской озвучкой. Тот же расклад знает и сам # поиск (torrcast/domain/wait_indexer.py: русскую озвучку несут оба, кворумный один). # Отсюда правило установки: судить по именам - значит звать урезанным полный каталог и # молчать про источник, которого никто не спрашивал. Урез - это роль, у которой не # ответил НИКТО; её и стережёт :func:`catalog_gate` - словами и ненулевым кодом возврата. CATALOG_ROLES=( "western releases and anime^западные релизы и аниме|$KEY_INDEXER" "Russian releases and voiceovers^русские раздачи и озвучки|rutor jacred" ) # Список короткий не по лени: пул прочёсан целиком и машинно. В схеме Prowlarr 622 # определения, из них открытых (privacy=public) - 86, и каждое прощупано с той самой # машины, где стоит torrcast: базовый адрес плюс все зеркала из схемы (74 адреса на # интересных для кино определениях), потом добавление в Prowlarr и живые поиски. # Итог прочёса: отвечают целиком 17, из них годных для кино - ноль сверх взятых. # Куда делись остальные 69: # * 51 не отвечает - тело обрывается на первых ~14 КБ и висит до таймаута. Это блок # по ИМЕНИ в TLS, и обойти его нашим шимом нельзя: почти все сидят за CDN, который # без имени в рукопожатии просто не отвечает (проверено обращением на IP origin'а - # из 53 сработал ровно один, и тот из списка ниже). Так лежат eztv, uindex, filemood, # torrent9, megapeer, opensharing, tokyotosho, dmhy, mikan, Anidex, nekobt, u3c3, # torrentkitty, rintornet, newstudio, torrent-pirat и прочие. # Технически эти 51 открываются: десинхронизация TLS-рукопожатия (постоянный демон # на очереди nftables плюс своя стратегия подмены под каждую мишень) поднимает 24 # хоста из 27. Не ставим не поэтому, а по замеру 09-08-2026: 17 определений из # этой полусотни заведены живьём и прогнаны 100 запросов - 60 ходовых и 40 нарочно # хвостовых (старое мировое кино, нишевые сериалы, старое аниме). ЕДИНСТВЕННЫМ # источником играбельного HD ни одно из них не стало ни разу, 0 запросов из 100. # Раздач они дают много (один TorrentDownload - 232 уникальных играбельных на # хвостовой полусотне), но это те же фильмы, что уже нашлись. Самое близкое к # пользе: на 2 запросах из 100 у нас есть только HEVC (показ его берёт перекодом, # но по умолчанию не выбирает), а у них тот же фильм в h264. # Цена измерена тем же прогоном: правило на весь :443 роняет Knaben, на котором # держится половина каталога, - с обходом 0 байт и обрыв по таймауту на 30 с, # без обхода 200 и 84 КБ за 0.7 с. Менять половину каталога на хвост, которого # нет, нельзя, а точечный список хостов - это ещё и демон, который надо чинить, # когда стратегия протухнет. # * 15 встречают JS-проверкой браузера (Cloudflare «Just a moment», DDoS-Guard): # 1337x, LimeTorrents, KickassTorrents, The Pirate Bay, TorrentDownloads, magnetcat, # TorrentProject2, zamundarip, 52bt, torrentcore, blueroms, btetree, extratorrent. # Headless-браузер ради них не городим - это уже не установка одним скриптом. # * остальные отвечают, но каталога не добавляют: internetarchive заводится, только # каждый поиск у него 16-30 с (штатный поиск - 1-3 с) и на контрольных названиях он # не нашёл ничего; magnetdownload, 0magnet, bangumi-moe, nipponsei, showrss в # Prowlarr виснут до таймаута на 4 поисках из 5; anisource, Catorrent и # TorrentProject2 отвечают быстро и пусто; sosulki и byrutor живы и быстры, но у # первого две раздачи на пять запросов, а второй - каталог игр («Сталкер» у него # про GSC Game World); TorrentsCSV отвечает за долю секунды из консоли, но в # Prowlarr примерно каждый десятый поиск висит до таймаута, а один такой индексер # задерживает весь поиск; NZBIndex - usenet, а мы качаем торренты. # * ACG.RIP отрезает часть сетей на уровне TCP (:443 обрывается мгновенно, из других # сетей отвечает) - обход означал бы возить трафик через чужую страну, а установка # обязана работать из коробки без таких костылей. По той же причине не взят # torrent.by: он единственный, кого шим бы вытащил, но и по имени, и через шим он # отдаёт пустую страницу. # Трекеры с логином, капчей или ключом сюда не попадают ни при каких условиях, а # semi-private (59 определений) не рассматриваются вовсе: там регистрация. PHASES="${TORRCAST_PHASES:-locale packages ffmpeg torrcast torrserver sources prowlarr indexers config hls receiver facts}" #: Локаль. Без UTF-8 в системе консоль крошит кириллицу: русское название приезжает #: в `cast` битым ещё до разбора аргументов. Целью берём ru_RU.UTF-8: в C.UTF-8 строки #: сравниваются побайтно, и русские названия в списках выстраиваются не по алфавиту #: («Ёлки» после «Яги»). Своя задаётся TORRCAST_LOCALE. if [ "$LANGUAGE" = ru ]; then DEFAULT_LOCALE=ru_RU.UTF-8; else DEFAULT_LOCALE=C.UTF-8; fi LOCALE="${TORRCAST_LOCALE:-$DEFAULT_LOCALE}" #: Куда честно отступаем, если цель не собралась: C.UTF-8 есть в любой современной glibc #: и не тянет за собой переводы. Сортировка станет побайтной, кириллица останется целой. LOCALE_FALLBACK="${TORRCAST_LOCALE_FALLBACK:-C.UTF-8}" # Выгрузка оценок IMDb под справку в меню франшизы # (torrcast/domain/facts/settings.py, RATINGS_PATH). Открытая, # без ключа и регистрации, обновляется у них ежедневно; 8.6 МБ в архиве. IMDB_RATINGS_URL="${TORRCAST_IMDB_RATINGS_URL:-https://datasets.imdbws.com/title.ratings.tsv.gz}" IMDB_RATINGS_PATH="${TORRCAST_IMDB_RATINGS_PATH:-$STATE_DIR/imdb-ratings.tsv}" # Ниже скольких голосов оценку не берём. Не вкусовщина, а размер: с порогом файл # худеет с 30 МБ до 2 (106 тысяч строк вместо полутора миллионов), а всё, что человек # станет искать в торрентах, набирает тысячи голосов с запасом. IMDB_MIN_VOTES="${TORRCAST_IMDB_MIN_VOTES:-1000}" # Русские прокатные имена IMDb под паспорт картины # (torrcast/domain/facts/settings.py, RU_NAMES_PATH): # когда у картины нет статьи в русской Википедии, оригинал и год добирает офлайн-карта. # Полные выгрузки - 735 МБ трафика (510 + 225); фильтр оставляет 11 МБ на диске. IMDB_AKAS_URL="${TORRCAST_IMDB_AKAS_URL:-https://datasets.imdbws.com/title.akas.tsv.gz}" IMDB_BASICS_URL="${TORRCAST_IMDB_BASICS_URL:-https://datasets.imdbws.com/title.basics.tsv.gz}" IMDB_NAMES_PATH="${TORRCAST_IMDB_NAMES_PATH:-$STATE_DIR/imdb-ru-names.tsv}" #: Как часто карту перечитываем, дней. Выгрузки тяжёлые, а устаревает карта медленно: #: месячной давности она не знает разве что новинок проката. IMDB_NAMES_DAYS="${TORRCAST_IMDB_NAMES_DAYS:-30}" # Источники, которые домашний канал может резать (см. фазу `sources`). PL_DEFS_URL="${TORRCAST_PL_DEFS_URL:-https://indexers.prowlarr.com/master/11}" DEFS_TARBALL="${TORRCAST_DEFS_TARBALL:-https://codeload.github.com/Prowlarr/Indexers/tar.gz/refs/heads/master}" SHIM_DIR="${TORRCAST_SHIM_DIR:-/etc/torrcast-shim}" SHIM_PORT="${TORRCAST_SHIM_PORT:-443}" #: Таблица имён. Отдельной переменной - чтобы песочница правила свою копию, а не систему. HOSTS_FILE="${TORRCAST_HOSTS:-/etc/hosts}" #: Как часто шим перерешает, идти ли к имени напрямую (секунды, 0 - не перерешать). #: 🔴 TC-260. Канал режет по имени не всегда: замер живьём - в 19:00 имя рвалось в 100% #: попыток, часом позже в 0% на обоих его адресах. Решение, принятое один раз при #: установке, к вечеру уже про другую сеть, поэтому оно и переигрывается. Четверть часа - #: это заметно чаще, чем меняется погода канала, и заметно реже, чем стоило бы внимания #: трекеру: четыре пробы за круг, по одной зараз. ROUTE_EVERY="${TORRCAST_ROUTE_EVERY:-900}" # Трекеры, чьё имя может не пройти по TLS. Поля: # имя | путь пробы | тело POST (пусто - GET) | кандидаты обхода через запятую # | размер страницы постраничного обхода (пусто - не нужен, TC-696). # Проба обязана просить ЗАМЕТНОЕ тело (десятки КБ): мелкий ответ проходит и через # троттлинг, обрыв ловится только на объёме. Более того - проба обязана просить РОВНО # то, что просит клиент: просила бы меньше, и имя объявлялось бы здоровым там, где # настоящий запрос клиента рвётся (обратная же подмена - просто лишний обход, он # почти ничего не стоит). Кандидаты - то, что умеет sni-shim.py: # `direct` (стучаться на IP origin'а: для IP-адреса SNI не отправляется вовсе), # `direct:другое-имя` (то же самое, но адрес берём у зеркала - это ДРУГОЙ край того же # каталога), `named` (адрес спрашиваем сами, имя в рукопожатии остаётся) и запасное имя # `https://…`, ведущее в тот же origin, - для тех, кто без SNI не отвечает. # 🔴 TC-267. Кандидат у трекера обязан быть не один. Единственный путь означает, что # любая заминка на нём - молчащий индексер: замер на живой машине, DNS не ответил пять # секунд, и nyaa с rutor разом получили `502 маршрут пуст`, пока у Knaben с его запасным # именем всё было хорошо. # Новый такой трекер заводится одной строкой здесь и строкой в INDEXERS. SHIMS=( # 🔴 TC-692. Первым теперь `named`: замер на живой машине - api.knaben.org переехал # за Cloudflare, а она рукопожатие БЕЗ имени (`direct`) не принимает вовсе (TLS alert # handshake failure за 0.1 с с обоих адресов зоны), и запасное имя knaben.eu с двух # разных сетей молчит до таймаута. С именем в рукопожатии тот же адрес отвечает # целиком за 0.3 с, когда канал имя не режет, - то есть `named` несёт всё время, # пока обход ещё прибит, а окно уже открылось (сторож снимает прибитие только после # двух удачных кругов). Оба старых кандидата оставлены после `named`: `direct` # падает за 0.1 с и оживёт, если API съедет с общего CDN; knaben.eu - единственный # край вне Cloudflare, если он воскреснет. # 🔴 TC-696. Канал режет ответ по ОБЪЁМУ тела (обрыв на ~17-19 КБ, даже сжатого), # а Prowlarr просит у Knaben сразу 100 раздач - число зашито в его сборке, настройки # нет. Поэтому пятое поле: шим разбивает такой запрос на страницы по 20 раздач # (замер: 8.7 КБ сжатых на страницу при пороге обрыва ~15-19 КБ), и каждая доезжает # сама, а клиенту склеивается одна выдача из ста. Проба просит те же 100, что и # Prowlarr: вердикт «имя режется» обязан мерить НАСТОЯЩИЙ запрос клиента, а не # удобный пробе - иначе имя объявлялось бы здоровым там, где выдача клиента рвётся # (или наоборот, гнало бы живое имя за шим по слишком жирной пробе). Менять размер # пробы отдельно от страницы бессмысленно: один порог заменялся бы другим без # выигрыша - выигрыш даёт только склейка. 'api.knaben.org|/v1|{"query":"матрица","search_type":"score","size":100}|named,direct,https://knaben.eu|20' # Запасного имени нет (зеркала - чужие обёртки за CDN: 403 либо обрыв тела, замер), # второго адреса у origin'а тоже нет. Что есть - второй СПОСОБ дойти до того же # адреса: с именем в рукопожатии. Сейчас его режут, но режут не всегда (TC-260), и # перебор кандидатов стоит одной попытки. 'nyaa.si|/?f=0&c=0_0&q=naruto||direct,named' # Тот же движок, что у nyaa.si, тот же обход - без имени в рукопожатии origin # отвечает целиком (замер: `curl -sk -H "Host: sukebei.nyaa.si" https://198.251.89.38/…` # → 200, тело настоящей выдачи). Проба - настоящий запрос клиента: Cardigann шлёт сюда # `c=0_0` умолчанием (схема Prowlarr, поле cat-id), а не `page=rss`, который годится # только для ручной проверки руками. 'sukebei.nyaa.si|/?f=0&c=0_0&q=naruto||direct,named' 'anilibria.top|/api/v1/app/search/releases?query=Kaiba||named,https://api.anilibria.app' # Запасного имени нет: соседние имена этого каталога либо отдают неполную цепочку # сертификата и рвут тело на первых же байтах, либо молчат до таймаута, либо больше # не разбираются в адрес (замер). Адрес у origin'а тоже один. Что есть - второй # СПОСОБ дойти до него: без имени в рукопожатии он отдаёт ровно ту же выдачу (замер: # 111923 Б обоими путями, побайтово одинаково, 0.5-0.6 с). Без этой строки источник # там, где режут по имени, молчал бы всегда и молчал бы пустой выдачей. 'api.jacred.su|/api/search?query=matrix&sort=sid&limit=100||direct,named' # У rutor.is тот же каталог на соседнем адресе (замер: та же страница, 96132 Б с # обоих), поэтому это честный второй край, а не переименование первого. 'rutor.info|/search/matrix||direct,direct:rutor.is,named' # У этого запасного имени нет, а без имени в рукопожатии его CDN отвечает 403 с обоих # адресов (замер, 0.1 с) - отсюда `named`. Ходить через шим ему нужно ради сжатия: # голая выдача этой пробы обрывается на 15 КБ и висит до таймаута, а сжатая - 4.8 КБ # и целиком за 0.9 с. Prowlarr сжатия не просит, шим просит за него. 'yts.gg|/api/v2/list_movies.json?query_term=matrix&limit=50||named' ) #: Браузерная подпись: без неё часть трекеров отвечает отказом ещё на пробе. UA="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122 Safari/537.36" # Раздача HLS: сегменты в tmpfs (фильм на диск не пишем). Транспорт по умолчанию — # голый http на IP: ни серта, ни имени, ни DNS в пути показа. Адрес раздачи не # настраивается — код сам берёт тот интерфейс, с которого хост виден телевизору. # TORRCAST_HLS_BASE_URL — запасной выход, если прямой путь не заработает. if [ "$OS_FAMILY" = macos ]; then # macOS has no /dev/shm or a tmpfs mounted by default. A RAM disk would have to be # recreated and mounted before every service starts, so use the OS temporary-data # area instead. This is reliable across boots, but writes the 0.5-1.2 GiB segment # window to disk during a show; setup_hls says that cost out loud. HLS_DIR="${TORRCAST_HLS_DIR:-/var/tmp/torrcast}" else HLS_DIR="${TORRCAST_HLS_DIR:-/dev/shm/torrcast}" fi HLS_PORT="${TORRCAST_HLS_PORT:-8080}" HLS_TRANSPORT="${TORRCAST_TRANSPORT:-http}" HLS_BASE_URL="${TORRCAST_HLS_BASE_URL:-}" HLS_HOST="${TORRCAST_HLS_HOSTNAME:-torrcast.local}" TLS_DIR="$CONFIG_DIR/tls" # --- Канал прогресса: подача узнаёт о ходе установки отсюда ------------------- # 🔴 TC-885. Полоса НЕ идёт по часам, и это главная развязка при переносе подачи # из референса. Референс был фейковым установщиком: процент считался от заданной # длительности, `pct = прошло * 100 / DURATION`. На боевой установке такой прибор # мерил бы не ту цель. Длительность заранее неизвестна (зеркала pypi, размер # апдейта, живость трекеров), фазы идут в фоне параллельно (:func:`job_start`), и # таймер честно показал бы 99 % на висящей загрузке ffmpeg - то есть соврал бы # ровно там, где смотреть и надо. # # Развязка: единственный источник процента - ЗАКРЫТАЯ ФАЗА. Установка (она же # «работник») зовёт :func:`phase_done` там, где фаза кончилась, строка уходит в # канал, а рисовалка читает канал и считает `pct = закрыто * 100 / всего # включённых`. Цена деления - 1/N, промежуточных значений нет вовсе: полосе # нечем показать процент, которого никто не закрыл. Часы в рисовалке остались # ровно на одно - на длину кадра. # # Канал - обычный файл, а не труба: труба на 64 КБ заткнулась бы, если бы # рисовалка отстала, и заткнула бы вместе с собой установку. Строку пишет один # `printf` в режиме дописывания, поэтому она доезжает целиком. UI_CHANNEL="${TORRCAST_UI_CHANNEL:-}" #: Разделитель полей внутри одной строки канала. Пробел тут не годится: в имени #: приёмника он бывает («Гостиная ТВ»), а строка канала должна делиться однозначно. UI_TAB=$'\t' ui_say() { # $1 - метка (P фаза, A итог, R приёмник, D ошибка, E конец), дальше текст local tag="$1"; shift [ -n "$UI_CHANNEL" ] || return 0 printf '%s %s\n' "$tag" "$*" >>"$UI_CHANNEL" 2>/dev/null || true } #: Фаза закрыта. Зовётся ТАМ, где результат фазы уже на месте, а не там, где её #: запустили: у фоновых заданий это `job_wait`, а не `job_start`. #: 🔴 TC-1052. Имя фазы двуязычно ровно той же формой, что `log`/`skip`/`info`: #: в английский канал уходил литерал `installation`, и человек двенадцать раз читал #: одно слово, отличавшееся только номером деления. Имена коротки с обеих сторон, #: самое длинное - `TorrServer`: строка статуса от пары шире не становится. phase_done() { # $1 - короткое имя фазы для статусбара (en), $2 - оно же (ru) if [ "$LANGUAGE" = ru ]; then ui_say P "$2"; else ui_say P "$1"; fi } log() { local text; if [ "$LANGUAGE" = ru ]; then text="$2"; else text="$1"; fi printf '\033[1m==>\033[0m %s\n' "$text" } skip() { if [ "$LANGUAGE" = ru ]; then printf ' уже на месте: %s\n' "$2" else printf ' already installed: %s\n' "$1"; fi } info() { if [ "$LANGUAGE" = ru ]; then printf ' %s\n' "$2"; else printf ' %s\n' "$1"; fi } # 🔴 TC-885. При включённой заставке заставка не вправе съесть строку ошибки: # сегодня `die` - единственный способ узнать, что установка не доделана. Поэтому # `die` не только печатает в журнал, но и говорит об этом в канал: рисовалка, # увидев метку D, РАЗВАЛИВАЕТ анимацию и показывает хвост журнала # (:func:`ui_collapse`). Ошибка - это конец установки, и последнее, что видит # владелец, обязано быть ею, а не справкой. die() { local text if [ "$LANGUAGE" = ru ]; then text="$2"; printf '\033[31mошибка:\033[0m %s\n' "$text" >&2 else text="$1"; printf '\033[31merror:\033[0m %s\n' "$text" >&2; fi ui_say D "$text" exit 1 } # Для того, что установку не роняет, но заметно урезает результат. Под заставкой # строка остаётся в журнале: отдельной панели для неё на экране больше нет. loud() { local text if [ "$LANGUAGE" = ru ]; then text="$2"; printf '\033[1;33mвнимание:\033[0m %s\n' "$text" >&2 else text="$1"; printf '\033[1;33mwarning:\033[0m %s\n' "$text" >&2; fi } # Выдачу права нельзя прятать в журнале. Эти строки рисовалка повторяет после # успешного итога; без заставки `loud` уже печатает их человеку сам. final_loud() { local text if [ "$LANGUAGE" = ru ]; then text="$2"; else text="$1"; fi loud "$1" "$2" ui_say A "$text" } has() { [[ " $PHASES " == *" $1 "* ]]; } # Не root - не падаем с просьбой повторить руками: под sudo установщик перезапускает # сам себя, и слово «sudo» произносит он, а не README. Права ему нужны по-настоящему - # apt, /opt, /etc, /usr/local/bin, юниты systemd, - безрутовой установки у продукта нет, # и вопрос был только в том, кто про них вспомнит. Читающий README уже root (обычный # случай в LXC, где sudo не поставлен вовсе) получал от нас команду, которой у него на # машине нет: `sudo: command not found` на первом же шаге. # 🔴 Переопределения тащим за sudo ЯВНО, через `env`, а не окружением: на части машин # sudo собран без сохранения окружения, и `-E` он молча игнорирует (та же грабля куплена # бутстрапом, см. become_root в `install`). # 🔴 И тащим ВСЕ TORRCAST_*, а не те, о которых вспомнил автор поднятия. Куплено живым # прогоном: `TORRCAST_PHASES="torrcast" ./install.sh` не-root'ом перезапустился под # sudo без этой переменной и вместо одной названной фазы прошёл установку целиком. # Молчаливая потеря переопределения хуже отказа: человек просил одно, а машине сделали # другое, и сказано об этом не было. Язык при этом остаётся собой: не названный в этот # заход в окружении не лежит, в список не попадает и доезжает молчанием (TC-955). # 🔴 TC-990. `-H` обязателен. Без него у поднятого root остаётся HOME позвавшего # человека, и pip дважды за установку отказывается от своего кэша вслух: «The # directory '/Users/…/Library/Caches/pip' … is not owned or is not writable by the # current user. The cache has been disabled». Кэш при этом переезжает в дом root # (/root/.cache/pip, на маке /var/root/Library/Caches/pip) - туда, где он и пишется, # и переживает повторную установку. Debian свой HOME сбрасывает и сам, а macOS его # хранит (`Defaults env_keep += "HOME MAIL"` в /etc/sudoers), поэтому увидено это # было на маке; ключ закрывает обе машины одинаково. # 🔴 И того же ключа мало. Установщик - не единственный, кто его ставит: человек # вправе поднять права САМ, `sudo ./install.sh`, и тогда за нашим `-H` уже никого # нет. Замер на стенде с макоподобным sudoers: `./install.sh` от обычного # пользователя - ноль предупреждений, `sudo ./install.sh` там же - те же два. # Поэтому дом root'у чинится по факту «мы root, а HOME чужой», а не по тому, # кто нас поднял. На Debian это пусто: тамошний sudo сбрасывает HOME и без ключа. become_root() { [ -n "${TORRCAST_NO_ROOT:-}" ] && return 0 if [ "$(id -u)" -eq 0 ]; then local root_home=~root if [ -n "${SUDO_USER:-}" ] && [ -d "$root_home" ] && [ "${HOME:-}" != "$root_home" ]; then export HOME="$root_home" fi return 0 fi if command -v "$SUDO" >/dev/null 2>&1; then if [ "$LANGUAGE" = ru ]; then printf 'не root - перезапуск через sudo\n' >&2 else printf 'not root - restarting through sudo\n' >&2; fi local keep=() name for name in $(compgen -v TORRCAST_); do keep+=("$name=${!name}"); done #: Язык, названный ключом (-ru/-en), в окружении не лежит: ключи съедены разбором #: выше, а за sudo их не передать - перезапуск идёт без аргументов, чтобы разбор #: не поехал второй раз. Поэтому названный ключом язык встаёт в тот же канал. [ -n "$LANGUAGE_NAMED" ] && keep+=("TORRCAST_LANGUAGE=$LANGUAGE_NAMED") exec "$SUDO" -H -- env ${keep[@]+"${keep[@]}"} "$SELF" fi die "run as root" "запускать от root" } # --- Задания в фоне ---------------------------------------------------------- # Половину установки занимало ожидание сети, и занимало ПО ОЧЕРЕДИ: статическая сборка # ffmpeg, два соседних сервера, пробы трекеров и определения индексеров ничего друг о # друге не знают, но каждое стояло в своей фазе (замер: 145 с всего, из них 96 - четыре # такие фазы подряд). Теперь они идут разом, а ждём каждое ровно там, где его результат # впервые нужен. # # Вывод задания копится в файл и печатается ЦЕЛИКОМ в момент ожидания: параллельные # фазы, пишущие вперемешку, превратили бы журнал установки в кашу, а он тут - главное # доказательство того, что произошло. Порядок строк внутри фазы поэтому цел, а сами # фазы приезжают в том порядке, в каком их результат понадобился. # # 🔴 Провал задания не тонет: `job_wait` возвращает его код, а вызывающий говорит о нём # словами и роняет установку там же, где ронял бы последовательный код. JOB_NAMES=() JOB_PIDS=() JOB_DIR="" job_pid_get() { local wanted="$1" i for (( i = 0; i < ${#JOB_NAMES[@]}; i++ )); do [ "${JOB_NAMES[$i]}" = "$wanted" ] && { printf '%s' "${JOB_PIDS[$i]}"; return; } done } job_start() { # $1 - имя задания, дальше команда с аргументами local name="$1"; shift [ -n "$JOB_DIR" ] || JOB_DIR="$(mktemp -d)" # 🔴 TC-638. Тело идёт в фоновой подоболочке под голым `set -e`: прежний запуск под # `|| rc=$?` выключал errexit на ВСЮ глубину вызова - вместе со всеми вложенными # функциями, и сбой посреди фазы проглатывался. Чем это кончалось: отвалившийся # curl|jq в расчёте кэша TorrServer код терял, кэш оставался дефолтным, а установка # рапортовала успех. Код и длительность пишет ловушка EXIT: до строк за телом оно # под errexit может просто не дойти. Длительность задание считает САМО, по своим # часам: снаружи видно только «сколько прошло до того, как его дождались», а это # совсем другое число. ( export JOB_EXIT_BEGAN="$SECONDS" # Bash 3.2 runs this EXIT trap after the function's local scope is gone. Keep # the name in the child shell itself, otherwise macOS expands an unset `name` # under set -u and loses both the real failure code and timing. JOB_EXIT_NAME="$name" trap 'rc=$?; printf "%s" "$rc" >"$JOB_DIR/$JOB_EXIT_NAME.rc"; printf "%s" "$((SECONDS - JOB_EXIT_BEGAN))" >"$JOB_DIR/$JOB_EXIT_NAME.took"' EXIT "$@" >"$JOB_DIR/$name.out" 2>&1 ) & JOB_NAMES+=("$name") JOB_PIDS+=("$!") } job_wait() { # $1 - имя задания; печатает его вывод, возвращает его код local name="$1" pid i pid="$(job_pid_get "$name")" [ -n "$pid" ] || return 0 # задание не запускалось - фаза выключена в PHASES wait "$pid" 2>/dev/null || true for (( i = 0; i < ${#JOB_NAMES[@]}; i++ )); do [ "${JOB_NAMES[$i]}" = "$name" ] || continue unset 'JOB_NAMES[i]' 'JOB_PIDS[i]' # Bash 3.2 treats expansion of an assigned-but-empty array as an unbound # variable under set -u. Compact only when an element survived the unset. if (( ${#JOB_NAMES[@]} )); then JOB_NAMES=("${JOB_NAMES[@]}"); JOB_PIDS=("${JOB_PIDS[@]}") else JOB_NAMES=(); JOB_PIDS=() fi break done [ -s "$JOB_DIR/$name.out" ] && cat "$JOB_DIR/$name.out" # Сколько фаза шла на самом деле. Не украшение: у параллельных фаз время из журнала # больше не читается (все их строки приезжают разом), а без него не видно, во что # установка упирается и стало ли ей от параллели легче. info "└ background: $(cat "$JOB_DIR/$name.took" 2>/dev/null || printf '?') s" "└ фоном: $(cat "$JOB_DIR/$name.took" 2>/dev/null || printf '?') с" return "$(cat "$JOB_DIR/$name.rc" 2>/dev/null || printf 1)" } # --- Догрев после «готово» --------------------------------------------------- # Не всё, что установка делает, нужно первому показу. Индексер, который добавляется сто # секунд, и восьмимегабайтная выгрузка оценок к меню - это догрев: `cast` играет и без # них. Такое доводится ПОСЛЕ того, как установка отчиталась, и человек про это узнаёт # строкой, а не молчанием. #: Что доводится в фоне - строки, которые `main` печатает сразу после «готово». Копятся в #: файле, а не в переменной: догрев заводится и из фоновых заданий, а те живут в своих #: подоболочках, и присвоение оттуда до `main` не доедет. LATE_NOTES="$(mktemp)" #: Куда догрев пишет свой итог: установка к этому времени уже отчиталась, и сказать #: «получилось» ей больше некуда. LATE_LOG="${TORRCAST_LATE_LOG:-$STATE_DIR/late.log}" # ⚠️ Отвязываемся от установки по-настоящему: свои stdin/stdout/stderr в файл (иначе ssh, # которым запускали установку, будет ждать закрытия трубы и «зависнет» уже после # «готово») и игнор SIGHUP (иначе закрытая консоль убьёт догрев на середине). late_run() { # $1/$2 - английское/русское имя, дальше команда с аргументами local note; if [ "$LANGUAGE" = ru ]; then note="$2"; else note="$1"; fi; shift 2 install -d -m 0755 "$(dirname "$LATE_LOG")" ( trap '' HUP if [ "$LANGUAGE" = ru ]; then w_start=начал; w_done=готово; w_fail="НЕ вышло"; w_tail="на показ не влияет" else w_start=started; w_done="done"; w_fail="FAILED"; w_tail="playback is not affected"; fi printf '%s | %s: %s\n' "$(date '+%F %T')" "$w_start" "$note" # Итог пишем в обеих ветках: догрев не обязан удаться, но обязан сказать. # 🔴 TC-638. Тело идёт под голым `set -e`, а ветку «не вышло» пишет ловушка # EXIT по коду подоболочки: прежний `if "$@"` выключал errexit на всю глубину # вызова, и сбой посреди догрева проглатывался - тело ехало дальше по битым # данным, а журнал рапортовал «готово». trap 'rc=$?; if [ "$rc" -eq 0 ]; then printf "%s | %s: %s\n" "$(date "+%F %T")" "$w_done" "$note"; else printf "%s | %s (%s): %s - %s\n" "$(date "+%F %T")" "$w_fail" "$rc" "$note" "$w_tail"; fi' EXIT "$@" ) >>"$LATE_LOG" 2>&1 >"$LATE_NOTES" } # Маска процесса для pgrep -f/pkill -f. Голая строка запуска шаблоном не годится: она # ищется как регулярка в ЛЮБОМ месте командной строки, поэтому под неё попадает чужой # процесс, который наш путь всего лишь упоминает в аргументах - редактор с открытым # файлом, grep по нему, less по логу. Прибиваем к началу строки запуска и требуем # границу слова в конце, а метасимволы (точки в путях, скобки) экранируем. proc_mask() { # $1 - начало строки запуска; печатает шаблон для -f printf '^%s( |$)' "$(printf '%s' "$1" | sed 's/[][^$.*+?(){}|\]/\\&/g')" } # Память ЭТОЙ машины, байты. Не только /proc/meminfo: под cgroup (контейнер, LXC) машине # видно больше, чем ей на деле дадут, - берём меньшее из двух. Ошибиться тут дорого: # именно на разнице между «памятью в meminfo» и реальным потолком контейнер и вставал. host_memory() { local mem lim if [ "${OS_FAMILY:-linux}" = macos ]; then # macOS не даёт процессу отдельного cgroup-потолка: hw.memsize здесь и есть # физическая память машины, без выдуманного второго ограничения. sysctl -n hw.memsize return fi mem=$(( $(awk '/^MemTotal:/{print $2}' /proc/meminfo) * 1024 )) while read -r lim; do case "$lim" in ''|*[!0-9]*) continue ;; esac [ "$lim" -gt 0 ] && [ "$lim" -lt "$mem" ] && mem="$lim" done </dev/null) EOF printf '%s' "$mem" } # Сколько памяти позволено самой службе раздачи, байты: всё, кроме запаса на остальных. ts_mem_budget() { local budget budget=$(( $(host_memory) - TS_MEM_RESERVE * 1024 * 1024 )) [ "$budget" -lt $(( TS_CACHE_MIN * TS_MEM_OVERHEAD )) ] \ && budget=$(( TS_CACHE_MIN * TS_MEM_OVERHEAD )) printf '%s' "$budget" } # Кэш раздачи В ПАМЯТИ, байты: бюджет службы, делённый на замеренный перерасход. ts_cache_ram() { local cache cache=$(( $(ts_mem_budget) / TS_MEM_OVERHEAD )) [ "$cache" -gt "$TS_CACHE_MAX" ] && cache="$TS_CACHE_MAX" [ "$cache" -lt 0 ] && cache=0 printf '%s' "$cache" } # Свободное место на разделе, где будет лежать кэш, байты. Каталога может ещё не быть - # спрашиваем ближайшего существующего предка, раздел у них один и тот же. disk_free() { # $1 каталог local dir="$1" while [ ! -d "$dir" ] && [ "$dir" != "/" ]; do dir="$(dirname "$dir")"; done if [ "${OS_FAMILY:-linux}" = macos ]; then # BSD df has no GNU -B1. -k is specified in 1024-byte blocks on macOS. df -Pk "$dir" 2>/dev/null | awk 'NR==2{printf "%.0f", $4 * 1024}' else df -P -B1 "$dir" 2>/dev/null | awk 'NR==2{print $4}' fi } # Кэш раздачи НА ДИСКЕ, байты: свободное место минус то, что диску нужно на прогрев, # состояние и систему. Кэш тут гость, а не хозяин: раздел, выеденный кэшем в ноль, # убьёт прогрев, то есть ровно то, ради чего запас и нужен. # Бюджет прогрева, байты. Спрашиваем ПАКЕТ, а не файл по пути: импорт идёт за ИМЕНЕМ и # переживает переезд модуля, разбор файла по пути - нет. # # 🔴 TC-621. Раньше тут разбирался AST у плоского фасада `torrcast/warm.py` (разрез его # снёс). Разрез увёз константу в `torrcast/domain/warm_settings.py`, и проба стала # печатать «не найден» - молча: её ненулевой код тонул (см. ⚠️ в `ts_cache_disk`), # пустую строку арифметика ниже читала как ноль, резерв прогрева пропадал из расчёта # кэша, а установка объявляла успех. # Установщик называл по именам ещё и `facts.py`, `search.py`, `state.py` - их разрез снёс # следующей волной, и все три имени тут переписаны на дома в слоях. # # ⚠️ Спрашиваем ИСХОДНИК репы (PYTHONPATH), а не venv: расчёт кэша идёт до того, как в # venv что-либо разложено, и на чистой машине импорт из venv не удался бы вовсе. # Поэтому же берём модуль без сторонних зависимостей - голого интерпретатора хватает. warm_budget() { local budget pick_python budget="$(PYTHONPATH="$REPO_DIR${PYTHONPATH:+:$PYTHONPATH}" "$PYTHON" -c \ 'from torrcast.domain.warm_settings import WARM_BUDGET; print(WARM_BUDGET)' 2>&1)" \ || { loud "torrcast package did not provide WARM_BUDGET: $budget" "пакет torrcast не отдал WARM_BUDGET: $budget"; return 1; } case "$budget" in ''|*[!0-9]*) loud "WARM_BUDGET is not an integer byte count: $budget" "WARM_BUDGET не целое число байт: $budget"; return 1 ;; esac printf '%s' "$budget" } # Каталог прогретого - спрашивается у ПАКЕТА, тем же приёмом и по той же причине, что # и бюджет: имя переживает переезд модуля, путь к файлу - нет. warm_dir() { local dir pick_python dir="$(PYTHONPATH="$REPO_DIR${PYTHONPATH:+:$PYTHONPATH}" "$PYTHON" -c \ 'from torrcast.domain.warm_settings import WARM_DIR; print(WARM_DIR)' 2>&1)" \ || { loud "torrcast package did not provide WARM_DIR: $dir" "пакет torrcast не отдал WARM_DIR: $dir"; return 1; } case "$dir" in /*) ;; *) loud "WARM_DIR is not an absolute path: $dir" "WARM_DIR не абсолютный путь: $dir"; return 1 ;; esac printf '%s' "$dir" } # Сколько байт уже занято прогретым, тем же счётом, что у щупа `cast doctor` # (`MachineProbe.warm_used`): считаются куски показа, а не весь каталог. warm_used() { local dir used dir="${TORRCAST_WARM:-$(warm_dir)}" || return 1 [ -d "$dir" ] || { printf '0'; return 0; } # 🔴 Складывает арифметика ОБОЛОЧКИ, а не awk. Замер на двух машинах: mawk 1.3.4 # 20200120 печатает `s+0` для 6413961908 как «6,41396e+09» - экспоненциальной # записью и с запятой из локали, - а `printf "%d"` упирается в 2147483647; mawk # 1.3.4 20250131 на том же выражении отдаёт целое. Нечисло проверка ниже прочитала # бы как ноль МОЛЧА, весь бюджет прогрева зарезервировался бы поверх уже занятого - # ровно та ошибка, ради которой функция и написана. Арифметика оболочки # 64-битная и локали не знает. # ⚠️ Рукава case внутри $(...) пишутся СО СКОБКОЙ - (`(''|*[!0-9]*)`). Стоковый # bash 3.2 мака ищет конец подстановки простым счётом скобок и обрезает её на # непарной `)` рукава: установка умирала на «command substitution: syntax error» # посреди фазы TorrServer (куплено живым прогоном на bash 3.2.57). if [ "${OS_FAMILY:-linux}" = macos ]; then # BSD find не знает -printf: размеры спрашиваем у BSD stat. used="$(find "$dir" -type f -name 'v*.ts' -exec stat -f '%z' {} + 2>/dev/null | { sum=0 while IFS= read -r size; do case "$size" in (''|*[!0-9]*) continue ;; esac sum=$(( sum + size )) done printf '%s' "$sum" })" else used="$(find "$dir" -type f -name 'v*.ts' -printf '%s\n' 2>/dev/null | { sum=0 while IFS= read -r size; do case "$size" in (''|*[!0-9]*) continue ;; esac sum=$(( sum + size )) done printf '%s' "$sum" })" fi case "$used" in ''|*[!0-9]*) printf '0'; return 0 ;; esac printf '%s' "$used" } ts_cache_disk() { local free cache reserve budget used free="$(disk_free "$TS_CACHE_DIR")" case "$free" in ''|*[!0-9]*) printf '0'; return 0 ;; esac # ⚠️ Провал пробы возвращаем ЯВНО, до самого `install_torrserver`. Раньше # полагаться тут на `set -e` было нельзя: `job_start` глушил errexit на всю # глубину вызова (TC-638), и провалившаяся проверка возвращала ноль. Теперь # errexit в теле задания жив, а явный код оставлен: ветка `|| die` в # `install_torrserver` называет причину словами, и терять её незачем. # # 🔴 TC-725. Из бюджета прогрева вычитается уже прогретое: свободное место раздела # его не содержит, и резерв поверх свободного считал бы занятое ДВАЖДЫ. Цена # ошибки замерена на машине, которая уже работала: раздел 52.7 ГБ, прогретого # 15.3 ГБ, свободно 23.1 ГБ - бюджет прогрева с запасом просил 33.2 ГБ, кэшу на # диске выходил ноль, и он уезжал в память. Там он стоит вдвое против себя # (TS_MEM_OVERHEAD): служба весила 5.9 ГиБ при 8 ГБ у машины вместо 104 МиБ. # Правило то же, что в домене (`torrcast.domain.warm_claim.warm_claim`). budget="$(warm_budget)" || return 1 used="$(warm_used)" || return 1 reserve=$(( budget - used )) [ "$reserve" -lt 0 ] && reserve=0 cache=$(( free - reserve - TS_DISK_FLOOR )) [ "$cache" -gt "$TS_CACHE_MAX" ] && cache="$TS_CACHE_MAX" [ "$cache" -lt 0 ] && cache=0 printf '%s' "$cache" } # Где кэшу больше места - там он и лежит. Печатает «disk|ram <байты>». # # Правило одно и оно про обещание, а не про красоту: запас показа на обрыв - это размер # кэша, поэтому берём то место, где кэш выходит БОЛЬШЕ. На обычной машине это диск (там # не с чем делить, и служба весит сотню мегабайт), на машине с большой памятью и тесным # диском - память. Ниже TS_CACHE_MIN не опускаемся ни там, ни там: без запаса вовсе # показ рвётся на первой же паузе провайдера. ts_cache_place() { local ram disk ram="$(ts_cache_ram)" disk="$(ts_cache_disk)" || return 1 if [ "$disk" -ge "$ram" ] && [ "$disk" -ge "$TS_CACHE_MIN" ]; then printf 'disk %s' "$disk" elif [ "$ram" -ge "$TS_CACHE_MIN" ]; then printf 'ram %s' "$ram" elif [ "$disk" -ge "$ram" ]; then printf 'disk %s' "$TS_CACHE_MIN" else printf 'ram %s' "$TS_CACHE_MIN" fi } # Значение ручки берётся в кавычки, и это не украшение: systemd режет строку # `Environment=` по пробелам и всё после первого пробела считает СЛЕДУЮЩИМ # присваиванием, а не продолжением значения. Ручка с пробелом внутри доезжает до # процесса обрезанной по первому пробелу - молча, потому что служба при этом исправно # поднимается, а увидеть потерю можно только в окружении живого процесса. Кавычки # ставятся здесь, а не в зовущем: забыть их в новой ручке иначе ничего не мешает. quoted_knobs() { # $1 - лишние строки секции [Service]; печатает их же с кавычками local line out="" while IFS= read -r line; do case "$line" in 'Environment="'*) ;; Environment=*) line="Environment=\"${line#Environment=}\"" ;; esac out="$out$line"$'\n' done </dev/null 2>&1 \ && { skip "$1 process (sandbox)" "процесс $1 (песочница)"; return 0; } info "starting $1 in the background (sandbox)" "запускаю $1 фоном (песочница)" # Потолки памяти в песочнице ставит не systemd, поэтому Environment= разбираем # сами: без них служба в песочнице росла бы иначе, чем в системе, и замер # песочницы ничего бы не говорил о живой машине. Строки MemoryMax= тут нечему # применить - в песочнице cgroup своего юнита нет. # Кавычки со значения снимает systemd, а в песочнице юнита нет и снимать их # некому: `export` получил бы имя, начинающееся с кавычки, отказал бы «not a # valid identifier» и увёл бы за собой всю установку (set -e). Ручка с пробелом # внутри без кавычек не запишется вовсе, поэтому снимаем их тут сами. local line knob while IFS= read -r line; do case "$line" in Environment=*) knob="${line#Environment=}" ;; *) continue ;; esac case "$knob" in \"*\") knob="${knob#\"}"; knob="${knob%\"}" ;; esac export "${knob%%=*}=${knob#*=}" done <"$PREFIX/$1.log" 2>&1 "$PREFIX/$1.log" 2>&1 /dev/null 2>&1; then launchd_bootout "$label" launchctl bootstrap system "/Library/LaunchDaemons/$label.plist" else skip "$1 launchd job" "задание launchd $1" fi return 0 fi # Изменившийся юнит - это не только daemon-reload: `enable --now` уже поднятую # службу не перезапустит, и она осталась бы жить со СТАРЫМИ потолками памяти. На # чистой машине перезапускать нечего, на обновлении - обязательно. # ⚠️ Поэтому спрашиваем ДО правки юнита, работала ли служба. Рестарт того, что # `enable --now` секунду назад подняло само, не просто лишний: он глушит службу # посреди её первого запуска, а systemd ждёт остановки до TimeoutStopSec. Prowlarr в # этот момент раскатывает свою базу и на SIGTERM не отзывается - замер на чистой # машине: 90 секунд установки ровно тут, на ожидании SIGKILL. local fresh=0 was_up=0 systemctl is-active --quiet "$1.service" && was_up=1 write_unit "$1" "$2" "$3" "$quoted" && fresh=1 systemctl enable --now "$1.service" [ "$fresh" = 1 ] && [ "$was_up" = 1 ] && systemctl restart "$1.service" return 0 } # bootout у launchd асинхронен: задание уходит из области не сразу, и поспешный # bootstrap того же label кончается гонкой - замер на стенде: bootstrap ответил 0, # а завершившийся чуть позже bootout снял уже НОВУЮ регистрацию, и служба исчезла # (`launchctl print`: Could not find service). Поэтому ждём, пока области не # перестанет его видеть. launchd_bootout() { # $1 - label; снимает задание и ждёт, пока область его держит launchctl bootout "system/$1" >/dev/null 2>&1 || true local i=0 while launchctl print "system/$1" >/dev/null 2>&1; do i=$((i + 1)) [ "$i" -ge 180 ] && return 1 sleep 1 done return 0 } # Погасить службу, чтобы следующий run_service поднял её заново: `enable --now` и # pgrep в песочнице живой процесс не трогают, а после обновления кода это и нужно. stop_service() { # $1 имя, $2 начало строки запуска для песочницы if [ -n "${TORRCAST_NO_SYSTEMD:-}" ]; then pkill -f -- "$(proc_mask "$2")" >/dev/null 2>&1 || true elif [ "${OS_FAMILY:-linux}" = macos ]; then # Ждём освобождения области, но отказом ожидания установку не рвём: при # `set -e` голый вызов убил бы её молча, без строки причины, а застрявший # bootout - это в худшем случае служба со старым кодом, а не отказ. launchd_bootout "org.torrcast.$1" || true else systemctl stop "$1.service" >/dev/null 2>&1 || true fi } wait_http() { # $1 url, $2 секунд local i=0 until curl -fsS -o /dev/null "$1" 2>/dev/null; do i=$((i + 1)); [ "$i" -ge "${2:-60}" ] && return 1; sleep 1 done } # Скачивание из сети - с повторами. Канал моргает: одна и та же ссылка отдаёт то # файл, то отказ на рукопожатии TLS («self-signed certificate» на github.com - # поймано на чистой машине, следующая же попытка прошла). Установка обязана # переживать такое сама: без этого один моргнувший байт роняет весь заход. DL_TRIES="${TORRCAST_DL_TRIES:-4}" fetch() { # $@ - аргументы curl; возвращает код последней попытки local i=1 rc while :; do if curl -fsSL --retry 2 --retry-connrefused --connect-timeout 20 "$@"; then return 0 else rc=$? fi # Временные HTTP-отказы уже повторяет сам curl. Постоянный отказ (в том # числе снятый архив с 404) надо сразу передать запасному источнику: # внешние повторы того же адреса добавляли три бесполезные паузы. [ "$rc" -eq 22 ] && return "$rc" [ "$i" -ge "$DL_TRIES" ] && return 1 # В stderr, а не в stdout: вывод fetch бывает и телом ответа, которое тут же # читает jq - строка про повтор посреди JSON ломала бы разбор. info "download failed (attempt $i of $DL_TRIES) - retrying" "не скачалось (попытка $i из $DL_TRIES) - пробую снова" >&2 sleep $((i * 3)) i=$((i + 1)) done } # Описание релиза с GitHub: сначала пиненный тег. Четвёртый аргумент запрещает # отступать на latest: TorrServer - часть тракта показа, и подмена проверенной версии # новой при очередной установке хуже честного отказа именно этой сменной фазы. # # «Тега нет» (404) и «до GitHub не достучались» (код 000, ответа не было вовсе) - два # РАЗНЫХ исхода, и обращать их в одно нельзя: поймано в замере установки, когда под # нагрузкой api.github.com не ответил, и пин TorrServer молча выключился. Пин - это # защита от того, что чужой релиз поедет и сломает показ, и терять её на моргнувшем # канале - значит не иметь её ровно тогда, когда она нужнее всего (плохой канал рвёт # и загрузки). Поэтому сетевой провал повторяется - конечно: три захода по 10 секунд, # в нормальный бюджет установки это ничего не добавляет (тег на месте - ответ первым # же заходом), а умершему GitHub не даст повесить заход насмерть. Повторять 404 # бессмысленно: GitHub ответил честно, и второй заход ничего не оживит. # Печатает JSON в stdout, все свои слова - в stderr, иначе jq подавится. GH_TAG_TRIES=3 gh_release() { # $1 - организация/репозиторий, $2 - тег пина, $3 - имя, $4 - только пин local api="https://api.github.com/repos/$1/releases" body code i=1 body="$(mktemp)" while :; do # curl -f при HTTP-ошибке выходит с кодом 22, но в %{http_code} всё равно пишет # честный ответ сервера (404, 503...). Код 000 - ответа не было вовсе: сети нет. code="$(curl -fsSL --connect-timeout 10 \ -o "$body" -w '%{http_code}' "$api/tags/$2" 2>/dev/null)" || true if [ "$code" = 200 ] || [ "$code" = 404 ] || [ "$i" -ge "$GH_TAG_TRIES" ]; then break fi info "$3: GitHub did not answer the request for tag $2 (status ${code:-none}) - retry $i of $((GH_TAG_TRIES - 1))" "$3: GitHub не ответил на запрос тега $2 (код ${code:-нет}) - повтор $i из $((GH_TAG_TRIES - 1))" >&2 sleep 3 i=$((i + 1)) done if [ "$code" = 200 ] && [ -s "$body" ]; then cat "$body"; rm -f "$body"; return 0 fi rm -f "$body" if [ -n "${4:-}" ]; then info "✗ $3: pinned version $2 is unavailable (status ${code:-none}) - not installing another version" "✗ $3: пиненная версия $2 недоступна (код ${code:-нет}) - другую версию не ставлю" >&2 return 1 fi if [ "$code" = 404 ]; then info "⚠ $3: pinned version $2 is no longer on GitHub (404) - using latest" "⚠ $3: пиненной версии $2 на GitHub больше нет (404) - беру latest" >&2 else info "⚠ $3: GitHub did not answer after $GH_TAG_TRIES attempts (status ${code:-none}) - using latest" "⚠ $3: до GitHub не достучался за $GH_TAG_TRIES попытки (код ${code:-нет}) - беру latest" >&2 fi info "⚠ version is unverified; installation will fail below if the API wrapper is incompatible" "⚠ версия непроверенная; если обвязка API не сойдётся - установка упадёт ниже" >&2 fetch "$api/latest" } # --- 0. Локаль -------------------------------------------------------------- # `locale -a` печатает имена в своём написании (`c.utf8`, `ru_RU.utf8`), поэтому # сравниваем приведёнными: без регистра и без дефисов. locale_key() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '-'; } locale_present() { # $1 - имя локали local want line want="$(locale_key "$1")" while read -r line; do [ "$(locale_key "$line")" = "$want" ] && return 0 done < <(locale -a 2>/dev/null) return 1 } sed_in_place() { if [ "${OS_FAMILY:-linux}" = macos ]; then sed -i '' "$@"; else sed -i "$@"; fi } # Собрать локаль, если её ещё нет. Возвращает 0, только когда после всех попыток она # реально есть в системе: собрать может быть нечем (нет ни locale-gen, ни localedef - # так бывает на урезанных образах), и это штатная ветка, а не ошибка. locale_build() { # $1 - имя локали locale_present "$1" && return 0 if ! command -v locale-gen >/dev/null 2>&1 && ! command -v localedef >/dev/null 2>&1; then apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y -qq locales fi if ! locale_present "$1" && [ -f /etc/locale.gen ] \ && command -v locale-gen >/dev/null 2>&1; then info "generating $1 with locale-gen" "собираю $1 через locale-gen" grep -qE "^[[:space:]]*$1([[:space:]]|\$)" /etc/locale.gen \ || printf '%s %s\n' "$1" "${1#*.}" >> /etc/locale.gen locale-gen >/dev/null 2>&1 || true fi if ! locale_present "$1" && command -v localedef >/dev/null 2>&1; then info "generating $1 with localedef" "собираю $1 через localedef" localedef -i "${1%%.*}" -f "${1#*.}" "$1" >/dev/null 2>&1 || true fi locale_present "$1" } # Готовим UTF-8 для процессов torrcast. Идемпотентно: собранную локаль не пересобираем. setup_locale() { log "UTF-8 locale ($LOCALE)" "локаль UTF-8 ($LOCALE)" if [ "${OS_FAMILY:-linux}" = macos ]; then # launchd и приложения macOS получают язык из пользовательских Preferences; # Linux-файлов /etc/default/locale и /etc/environment здесь нет и не нужно. skip "locale is managed by macOS; no system files to change" "локалью управляет macOS; системные файлы менять не нужно" return fi locale_build "$LOCALE" || true # Цель не вышла - отступаем на C.UTF-8, и вслух: русская сортировка пропадёт, но # показывать это как успех нельзя. if ! locale_present "$LOCALE" && [ "$LOCALE" != "$LOCALE_FALLBACK" ]; then info "$LOCALE could not be generated on this system - using $LOCALE_FALLBACK; sorting will be bytewise" "$LOCALE в этой системе не собралась - беру $LOCALE_FALLBACK, сортировка будет побайтной" LOCALE="$LOCALE_FALLBACK" locale_build "$LOCALE" || true fi # Не вышло и с ней - берём любую готовую UTF-8: кириллица в консоли важнее, чем # конкретное имя локали. if ! locale_present "$LOCALE"; then local ready; ready="$(locale -a 2>/dev/null | grep -i -m1 'utf-\?8$' || true)" [ -n "$ready" ] || die "the system has no UTF-8 locale and none could be generated" "в системе нет ни одной UTF-8 локали и собрать её не вышло" info "$LOCALE was not generated - using available $ready" "$LOCALE не собралась - беру готовую $ready" LOCALE="$ready" fi } # --- 1. Зависимости --------------------------------------------------------- #: python3-venv обязателен: на голом Debian `python3 -m venv` без него не работает. APT_PACKAGES=(ffmpeg curl ca-certificates jq tar openssl python3-venv) # На macOS curl, bsdtar, LibreSSL и системное доверие уже даёт сама ОС; python venv # входит в brew python. Формулами ставятся только jq, Python >= 3.12 и ffmpeg >= 7. BREW_PACKAGES=(jq python@3.12 ffmpeg) #: Официальный установщик Homebrew. Переопределение - для стендовой проверки шва, #: живой URL меняется только вслед за brew.sh. HOMEBREW_INSTALL_URL="${TORRCAST_HOMEBREW_URL:-https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh}" homebrew_bin() { # печатает путь к brew; код 1 - его нет local found found="$(command -v brew 2>/dev/null || true)" [ -n "$found" ] || found=/opt/homebrew/bin/brew [ -x "$found" ] || return 1 printf '%s' "$found" } #: Homebrew ставим САМИ, официальным установщиком. Под root он не работает вовсе #: («Don't run this as root!»), поэтому ставит позвавший sudo. И строго безголово: #: без NONINTERACTIVE=1 установщик с tty на stdin звонит и ждёт нажатия RETURN #: (wait_for_user), вися установку насмерть. Вслух, а не молча: чужой пакетный #: менеджер на машине человека - событие, о котором говорят. Весь вывод шага - в #: stderr: brew_as_invoker зовут и из $(...), где чужой stdout стал бы «ответом» brew. install_homebrew() { loud "Homebrew is not installed - installing it as $SUDO_USER (https://brew.sh)" \ "Homebrew не установлен - ставлю его под $SUDO_USER (https://brew.sh)" local script; script="$(mktemp "${TMPDIR:-/tmp}/homebrew-install.XXXXXX")" fetch -o "$script" "$HOMEBREW_INSTALL_URL" \ || die "could not download the Homebrew installer from $HOMEBREW_INSTALL_URL" \ "не скачался установщик Homebrew: $HOMEBREW_INSTALL_URL" # mktemp даёт 0600 владельцу-root, а читать файл будет позвавший sudo - без этого # bash отвечает Permission denied (куплено живым прогоном на маке). chmod 0644 "$script" # NONINTERACTIVE сажаем через env ПОСЛЕ sudo: sudo режет чужие переменные окружения. "$SUDO" -H -u "$SUDO_USER" env NONINTERACTIVE=1 /bin/bash "$script" >&2 \ || die "the Homebrew installer failed" "установщик Homebrew не отработал" rm -f "$script" hash -r } brew_as_invoker() { [ -n "${SUDO_USER:-}" ] || die \ "Homebrew cannot run as root and SUDO_USER is empty; run this installer with sudo from a regular account" \ "Homebrew не работает под root, а SUDO_USER пуст; запусти установщик через sudo из обычной учётной записи" id "$SUDO_USER" >/dev/null 2>&1 || die \ "sudo invoker $SUDO_USER does not exist" "позвавшего sudo пользователя $SUDO_USER не существует" local brew_bin # Установка живёт в самом шве, а не шагом фазы: фазы зовут выборочно # (TORRCAST_PHASES), и шаг можно было бы пропустить, а мимо шва не пройти. # Идемпотентность - проверкой «уже есть» выше: повторные вызовы ничего не ставят. if ! brew_bin="$(homebrew_bin)"; then install_homebrew brew_bin="$(homebrew_bin)" || die \ "the Homebrew installer finished but brew still does not run" \ "установщик Homebrew отработал, а brew всё ещё не запускается" fi # brew получает uid позвавшего sudo пользователя, а не запрещённый ему uid root. "$SUDO" -H -u "$SUDO_USER" "$brew_bin" "$@" } uv_bin() { # печатает путь к uv; код 1 - его нет if [ -x "$UV_DIR/uv" ]; then printf '%s' "$UV_DIR/uv" return 0 fi local found found="$(command -v uv 2>/dev/null || true)" [ -n "$found" ] || return 1 printf '%s' "$found" } #: uv ставим САМИ, официальным установщиком, и вслух: чужой инструмент на машине #: человека - событие, о котором говорят. UV_INSTALL_DIR уводит бинарь в продуктовый #: каталог (в $HOME ему делать нечего: под sudo он чужой), INSTALLER_NO_MODIFY_PATH #: оставляет в покое профили оболочки - PATH человека не наше имущество. Весь вывод #: шага в stderr: шаг зовут из ветки, чей stdout читают как путь к интерпретатору. install_uv() { loud "no python 3.12 on this machine - installing uv into $UV_DIR to bring one (https://astral.sh)" \ "python 3.12 в системе нет - ставлю uv в $UV_DIR, он принесёт интерпретатор (https://astral.sh)" local script; script="$(mktemp "${TMPDIR:-/tmp}/uv-install.XXXXXX")" fetch -o "$script" "$UV_INSTALL_URL" \ || die "could not download the uv installer from $UV_INSTALL_URL" \ "не скачался установщик uv: $UV_INSTALL_URL" UV_INSTALL_DIR="$UV_DIR" INSTALLER_NO_MODIFY_PATH=1 sh "$script" >&2 \ || die "the uv installer failed" "установщик uv не отработал" rm -f "$script" } #: Интерпретатор 3.12 руками uv - для машин, где своего такого нет (Debian 12). Сначала #: спрашиваем уже принесённый и только потом качаем: повторная установка обязана быть #: тихой и быстрой, а не тянуть те же 30 МБ второй раз. Каталог принесённого назван #: явно (UV_PYTHON_INSTALL_DIR), иначе `find` и `install` смотрели бы в $HOME, который #: у перезапуска под sudo меняется, - и каждый заход выглядел бы как первый. bring_python() { local uv found if ! uv="$(uv_bin)"; then install_uv uv="$(uv_bin)" || die "the uv installer finished but uv still does not run" \ "установщик uv отработал, а uv всё ещё не запускается" fi export UV_PYTHON_INSTALL_DIR="$UV_PYTHON_DIR" #: Спрашиваем РОВНО принесённое uv (`--managed-python`) и мимо здешнего проекта #: (`--no-project`): без этих двух слов `find` отвечает первым, что видит, - и на #: машине, где установку запустили из каталога с `.venv`, отвечает венвом. Продукт #: уехал бы жить в чужое временное окружение, которое назавтра сотрут. found="$("$uv" python find --managed-python --no-project '>=3.12' 2>/dev/null || true)" if [ -n "$found" ]; then skip "python 3.12 brought by uv ($found)" "принесённый uv python 3.12 ($found)" else loud "uv is bringing python 3.12 into $UV_PYTHON_DIR - the system has none" \ "uv несёт python 3.12 в $UV_PYTHON_DIR - в системе его нет" "$uv" python install 3.12 >&2 \ || die "uv could not install python 3.12" "uv не смог поставить python 3.12" found="$("$uv" python find --managed-python --no-project '>=3.12' 2>/dev/null || true)" fi [ -n "$found" ] && [ -x "$found" ] || die \ "python 3.12 or newer is required and uv could not bring it (see requires-python in pyproject.toml)" \ "нужен python 3.12 или новее, и принести его через uv не вышло (см. requires-python в pyproject.toml)" PYTHON="$found" } # Самый свежий интерпретатор не ниже 3.12. Явный TORRCAST_PYTHON уважаем как есть. # Своего такого в системе нет - зовём uv; там, где есть, uv не участвует вовсе. pick_python() { [ -n "$PYTHON" ] && return 0 local candidate for candidate in python3.14 python3.13 python3.12 python3; do command -v "$candidate" >/dev/null 2>&1 || continue if "$candidate" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)'; then PYTHON="$candidate" return 0 fi done bring_python } #: ffmpeg не ниже 6.1 — из-за -readrate_initial_burst. В Debian 12 живёт 5.1, а без burst #: темп упаковки лечится только паузой процесса — той самой, под которой приёмник намертво #: виснет в BUFFERING. Статическая сборка кладётся в /usr/local/bin и перебивает пакетную #: по PATH; пакет ffmpeg остаётся на месте как запасной вариант. #: ⚠️ Сборка именно BtbN. Статик johnvansickle 7.0.2 на Xeon E5-2696 v4 ставится и версию #: печатает, а на первом же MPEG-TS падает в segfault (проверено дважды, с записями в #: dmesg) — то есть ломается ровно на том формате, в котором мы пакуем. FFMPEG_MIN="${TORRCAST_FFMPEG_MIN:-6.1}" FFMPEG_URL="${TORRCAST_FFMPEG_URL:-https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-linux64-gpl-7.1.tar.xz}" #: Запасной источник на ДРУГОМ хосте (не GitHub): если BtbN недоступен целиком (блок, #: снятый релиз) - берём портативную статическую сборку jellyfin-ffmpeg. Это своя сборка #: проекта, не johnvansickle, и на MPEG-TS её всё равно проверяет ffmpeg_smoke ниже. FFMPEG_URL2="${TORRCAST_FFMPEG_URL2:-https://repo.jellyfin.org/files/ffmpeg/linux/latest-7.x/amd64/jellyfin-ffmpeg_7.1.4-3_portable_linux64-gpl.tar.xz}" # Debian сохраняет свою точную семантику dpkg. На macOS сравниваются версии соседних # бинарей, где достаточно последовательности числовых компонентов (6.1, 7.1.1 и т.п.). version_ge() { if [ "${OS_FAMILY:-linux}" = linux ]; then dpkg --compare-versions "$1" ge "$2" else python3 - "$1" "$2" <<'PY' import re, sys def parts(value): return tuple(int(item) for item in re.findall(r"\d+", value)) raise SystemExit(0 if parts(sys.argv[1]) >= parts(sys.argv[2]) else 1) PY fi } ffmpeg_version() { # $1 — путь/имя бинаря; печатает голую версию либо ничего "$1" -version 2>/dev/null | head -1 | awk '{print $3}' | sed 's/^[^0-9]*//' } # Проверка сборки в деле, а не по строчке версии: пакуем секунду MPEG-TS (наш формат) # и читаем её обратно. Segfault ловится здесь, а не на живом показе. ffmpeg_smoke() { # $1 — каталог для файла, $2/$3 — ffmpeg/ffprobe (по умолчанию свои) local clip="$1/smoke.ts" ff="${2:-/usr/local/bin/ffmpeg}" fp="${3:-/usr/local/bin/ffprobe}" "$ff" -hide_banner -loglevel error -y \ -f lavfi -i "testsrc=size=320x240:rate=25:duration=1" -f lavfi -i "sine=duration=1" \ -c:v libx264 -pix_fmt yuv420p -preset ultrafast -c:a aac -f mpegts "$clip" || return 1 "$fp" -v error -show_entries stream=codec_name -of csv "$clip" >/dev/null || return 1 info "build verified: MPEG-TS can be muxed and read" "сборка проверена: MPEG-TS пакуется и читается" } #: TC-1048. Версия и текст помощи не защищают от 8.0.1: он печатает #: -readrate_initial_burst, берёт 8.0.1 ≥ 6.1 и переживает секунду MPEG-TS - а флаг #: у него инертен, и темп чтения при перемотке считается от НАЧАЛА файла, а не от #: места входа. На боевой команде упаковки это вешает перемотку намертво: заход на #: 4884-ю секунду - ~4894 с ожидания первого куска у 8.0.1 против 0.36 с у 7.1.4. #: Порог - по поведению, а не по номеру: числа ниже перепроверены на четырёх #: сборках (6.1.1, 7.1.4, 7.1.5, 8.0.1 - двоичные, не по документации). У трёх #: годных burst и посадка на 10-й секунде укладываются в 0.02-0.13 с при любой #: нагрузке машины; у сломанной burst почти не отличим от чтения без темпа вовсе #: (7.7 с из 8 заказанных), а посадка на 10-й секунде ждёт больше 11 с. Запас #: (3 с сверх базовой линии при готовых 0.02-0.13 с) - на порядок шире дрожания. FFMPEG_PACE_BURST=8 # с; столько просим прочитать при включённом burst FFMPEG_PACE_ENTRY=10 # с; куда садимся -ss FFMPEG_PACE_ENTRY_READ=3 # с; сколько читаем после посадки FFMPEG_PACE_MARGIN=3 # с; допуск сверх базовой линии без темпа FFMPEG_PACE_DEADLINE=5 # с; потолок каждой пробы - инертная/путающая всё равно его достаёт # Гонит команду $2.. не дольше $1 секунд, кладёт целые секунды в NOW_ELAPSED. # `timeout` на маке нет (это GNU coreutils), поэтому дедлайн - своя пара # «фоновый sleep + kill», а часы - встроенный $SECONDS: он живёт и в bash 3.2. _pace_run() { local deadline="$1"; shift SECONDS=0 "$@" >/dev/null 2>&1 & local pid=$! ( sleep "$deadline"; kill "$pid" 2>/dev/null ) & local guard=$! wait "$pid" 2>/dev/null kill "$guard" 2>/dev/null; wait "$guard" 2>/dev/null NOW_ELAPSED=$SECONDS } # Меряет поведение, а не справку. $1 - каталог для проб (клип и вывод туда же), # $2 - путь к ffmpeg (по умолчанию свой). 0 - burst не инертен и темп считается # от места входа, 1 - хотя бы одно из двух сломано. ffmpeg_paced_ok() { local dir="$1" ff="${2:-/usr/local/bin/ffmpeg}" local clip="$dir/pace.ts" total=$(( FFMPEG_PACE_ENTRY + FFMPEG_PACE_ENTRY_READ + 4 )) "$ff" -hide_banner -loglevel error -y \ -f lavfi -i "testsrc=size=320x240:rate=25:duration=$total" \ -f lavfi -i "sine=duration=$total" \ -c:v libx264 -pix_fmt yuv420p -preset ultrafast -g 25 -keyint_min 25 \ -c:a aac -f mpegts "$clip" || return 1 # Базовая линия: без темпа вовсе - сколько вообще стоит прочитать этот клип. _pace_run "$FFMPEG_PACE_DEADLINE" "$ff" -hide_banner -loglevel error -y \ -readrate 0 -copyts -i "$clip" -t "$FFMPEG_PACE_BURST" -c copy -f null - local base=$NOW_ELAPSED local limit=$(( base + FFMPEG_PACE_MARGIN )) # burst: честная сборка отдаёт заказанные секунды почти как без темпа вовсе; # инертная платит за них полным темпом, то есть почти всем их числом в секундах. _pace_run "$FFMPEG_PACE_DEADLINE" "$ff" -hide_banner -loglevel error -y \ -readrate 1 -readrate_initial_burst "$FFMPEG_PACE_BURST" -copyts -i "$clip" \ -t "$FFMPEG_PACE_BURST" -c copy -f null - if [ "$NOW_ELAPSED" -gt "$limit" ]; then rm -f "$clip" info "-readrate_initial_burst is inert on this build (${NOW_ELAPSED}s for ${FFMPEG_PACE_BURST}s of burst, baseline ${base}s)" \ "-readrate_initial_burst инертен на этой сборке (${NOW_ELAPSED} с на ${FFMPEG_PACE_BURST} с burst против базовой линии ${base} с)" return 1 fi # Посадка -ss глубоко в файл при -copyts: честная сборка считает темп от неё, # сломанная - от нулевой секунды файла, и на глубокой посадке платит почти всем # расстоянием до неё в секундах ожидания вместо долей секунды. _pace_run "$FFMPEG_PACE_DEADLINE" "$ff" -hide_banner -loglevel error -y \ -readrate 1 -copyts -ss "$FFMPEG_PACE_ENTRY" -i "$clip" \ -t "$FFMPEG_PACE_ENTRY_READ" -c copy -f null - rm -f "$clip" if [ "$NOW_ELAPSED" -gt "$limit" ]; then info "pace is counted from the start of the file, not from the seek point (${NOW_ELAPSED}s to read ${FFMPEG_PACE_ENTRY_READ}s landing ${FFMPEG_PACE_ENTRY}s in)" \ "темп считается от начала файла, а не от места входа (${NOW_ELAPSED} с на чтение ${FFMPEG_PACE_ENTRY_READ} с с посадки на ${FFMPEG_PACE_ENTRY}-й секунде)" return 1 fi info "build verified: burst pace is honored and counted from the seek point" \ "сборка проверена: burst соблюдён, темп считается от места входа" } #: Сборка из snap проходит проверку по версии, а конфайнмент не пускает её ни в каталог #: пакета, ни в состояние, ни в /dev/shm с сегментами: установка отчитывается зелёным, #: а показ разваливается на первом же файле. Такой ffmpeg считаем негодным независимо #: от версии - и ставим свою статическую сборку. ffmpeg_confined() { # $1 - имя/путь бинаря; 0 = заперт в снапе local real target target="$(command -v -- "$1" 2>/dev/null || true)" if [ "${OS_FAMILY:-linux}" = macos ]; then real="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$target" 2>/dev/null || true)" else real="$(readlink -f "$target" 2>/dev/null || true)" fi case "$real" in /snap/*|/var/lib/snapd/*) return 0 ;; *) return 1 ;; esac } #: Уже стоящую свою сборку спрашиваем ПО ПУТИ, а не по PATH: на PATH её заслоняет #: любой другой ffmpeg (снап, пакетный), и повторный заход качал бы те же десятки #: мегабайт заново. Проверка та же, что и для чужой сборки: версия плюс MPEG-TS в деле, #: то есть «уже на месте» говорится только про годный бинарь. #: ⚠️ Спрашивать версию у отсутствующего файла нельзя: под `set -e` установка оборвётся #: на коде 127 - отсюда проверка на -x до вопроса. ffmpeg_ours_ok() { # 0 = /usr/local/bin/ffmpeg на месте, годной версии и живой [ -x /usr/local/bin/ffmpeg ] && [ -x /usr/local/bin/ffprobe ] || return 1 local mine probe rc=1 mine="$(ffmpeg_version /usr/local/bin/ffmpeg || true)" [ -n "$mine" ] || return 1 version_ge "$mine" "$FFMPEG_MIN" 2>/dev/null || return 1 probe="$(mktemp -d)" # Строку про пройденный MPEG-TS уводим в stderr: stdout тут - это версия, которую # читает вызывающий, и чужая строка в нём стала бы «версией». ffmpeg_smoke "$probe" /usr/local/bin/ffmpeg /usr/local/bin/ffprobe >&2 \ && ffmpeg_paced_ok "$probe" /usr/local/bin/ffmpeg >&2 && rc=0 rm -rf "$probe" [ "$rc" = 0 ] || return 1 printf '%s' "$mine" } install_ffmpeg() { local have reject="" mine if [ "${OS_FAMILY:-linux}" = macos ]; then have="$(ffmpeg_version ffmpeg || true)" if [ -z "$have" ] || ! version_ge "$have" "$FFMPEG_MIN" 2>/dev/null; then brew_as_invoker install ffmpeg fi hash -r local ff fp probe ff="$(brew_as_invoker --prefix ffmpeg)/bin/ffmpeg" fp="$(brew_as_invoker --prefix ffmpeg)/bin/ffprobe" have="$(ffmpeg_version "$ff")" version_ge "$have" "$FFMPEG_MIN" 2>/dev/null \ || die "Homebrew installed ffmpeg $have, need >= $FFMPEG_MIN" "Homebrew поставил ffmpeg $have, нужно ≥ $FFMPEG_MIN" probe="$(mktemp -d)" ffmpeg_smoke "$probe" "$ff" "$fp" || die \ "Homebrew ffmpeg $have failed the MPEG-TS check" "ffmpeg $have из Homebrew не прошёл проверку MPEG-TS" ffmpeg_paced_ok "$probe" "$ff" || die \ "Homebrew ffmpeg $have failed the pace check (burst is inert or pace ignores the seek point)" \ "ffmpeg $have из Homebrew не прошёл проверку темпа (burst инертен или темп не знает про место входа)" rm -rf "$probe" # brew кладёт бинари в /opt/homebrew/bin, которого нет ни у launchd, ни у # sudo, ни у нелогинного ssh - только у терминала через path_helper. Ссылки в # /usr/local/bin (он в /etc/paths) делают ffmpeg/ffprobe видимыми отовсюду. ln -sfn "$ff" "$BIN_DIR/ffmpeg" ln -sfn "$fp" "$BIN_DIR/ffprobe" info "Homebrew ffmpeg $have ($ff), linked into $BIN_DIR" "ffmpeg $have из Homebrew ($ff), ссылки в $BIN_DIR" return fi # Своя сборка уже стоит - ничего не качаем. Переставить принудительно: удалить # /usr/local/bin/ffmpeg (так же переставляются TorrServer и Prowlarr) и запустить # установку снова. if mine="$(ffmpeg_ours_ok)"; then skip "static ffmpeg build $mine in /usr/local/bin (to reinstall, delete the file and run again)" \ "статическая сборка ffmpeg $mine в /usr/local/bin (переставить - удали файл и запусти снова)" return fi # `|| true` не для красоты: ffmpeg на PATH может не быть вовсе (в репозитории он # заведомо стар, и мы его не ставили), а под `set -e` пустой ответ отсюда оборвал бы # установку на коде 127. have="$(ffmpeg_version ffmpeg || true)" if [ -n "$have" ] && ffmpeg_confined ffmpeg; then info "ffmpeg $have is from snap: confinement blocks $PREFIX, $STATE_DIR and $HLS_DIR - using a static build" \ "ffmpeg $have из snap: в $PREFIX, $STATE_DIR и $HLS_DIR его конфайнмент не пустит - беру статическую сборку" reject=1 fi if [ -z "$reject" ] && [ -n "$have" ] \ && version_ge "$have" "$FFMPEG_MIN" 2>/dev/null; then # Версия - только полдела: проверяем найденный на PATH бинарь в деле, тем же # MPEG-TS, что и свою сборку. Не пережил - ставим свою. local ff fp probe ff="$(command -v ffmpeg)"; fp="$(command -v ffprobe || true)" probe="$(mktemp -d)" if [ -n "$fp" ] && ffmpeg_smoke "$probe" "$ff" "$fp" && ffmpeg_paced_ok "$probe" "$ff"; then rm -rf "$probe" skip "ffmpeg $have (need >= $FFMPEG_MIN: -readrate_initial_burst, honored in practice)" "ffmpeg $have (нужно ≥ $FFMPEG_MIN: -readrate_initial_burst, и он не инертен на деле)" return fi rm -rf "$probe" info "ffmpeg $have failed the MPEG-TS or pace check - using a static build" "ffmpeg $have не прошёл проверку MPEG-TS или темпа - беру статическую сборку" reject=1 fi [ "$(uname -m)" = "x86_64" ] || die "no static ffmpeg build for $(uname -m) - install ffmpeg >= $FFMPEG_MIN yourself" \ "статической сборки ffmpeg под $(uname -m) нет - поставь ffmpeg ≥ $FFMPEG_MIN сам" info "ffmpeg ${have:-none} - using a static build" "ffmpeg ${have:-нет} - беру статическую сборку" # Каталог убираем сами: `trap ... RETURN` без `set -T` цепляется ко ВСЕМ функциям # сразу и падает на первом же чужом return («work: unbound variable»). local work; work="$(mktemp -d)" # Идём по источникам на разных хостах: первый отдавший годный архив с бинарём и # побеждает. Так падение одного хоста (BtbN снят/заблокирован) не валит установку. local url bin="" for url in "$FFMPEG_URL" "$FFMPEG_URL2"; do [ -n "$url" ] || continue info "downloading static ffmpeg: ${url#https://}" "качаю статический ffmpeg: ${url#https://}" if fetch -o "$work/ffmpeg.tar.xz" "$url" \ && tar -xf "$work/ffmpeg.tar.xz" -C "$work" 2>/dev/null \ && bin="$(find "$work" -type f -name ffmpeg -perm -u+x | head -1)" \ && [ -n "$bin" ]; then break fi info "source did not provide a usable archive: ${url#https://} - trying the next one" "источник не дал годного архива: ${url#https://} - пробую следующий" rm -rf "${work:?}"/* 2>/dev/null || true bin="" done [ -n "$bin" ] || die "could not obtain a static ffmpeg build from any source" "статическую сборку ffmpeg не удалось получить ни с одного источника" install -d -m 0755 /usr/local/bin install -m 0755 "$bin" /usr/local/bin/ffmpeg install -m 0755 "$(dirname "$bin")/ffprobe" /usr/local/bin/ffprobe hash -r local now; now="$(ffmpeg_version /usr/local/bin/ffmpeg)" version_ge "$now" "$FFMPEG_MIN" 2>/dev/null \ || die "installed ffmpeg $now is still older than $FFMPEG_MIN" "поставился ffmpeg $now - это всё ещё ниже $FFMPEG_MIN" ffmpeg_smoke "$work" || die "ffmpeg build $now failed the MPEG-TS check - use another URL" "сборка ffmpeg $now не пережила MPEG-TS - другой URL" ffmpeg_paced_ok "$work" /usr/local/bin/ffmpeg || die \ "ffmpeg build $now failed the pace check (burst is inert or pace ignores the seek point) - use another URL" \ "сборка ffmpeg $now не прошла проверку темпа (burst инертен или темп не знает про место входа) - другой URL" rm -rf "$work" local packaged; packaged="$(ffmpeg_version /usr/bin/ffmpeg || true)" info "ffmpeg $now -> /usr/local/bin${packaged:+ (packaged $packaged remains)}" "ffmpeg $now → /usr/local/bin${packaged:+ (пакетная $packaged осталась)}" } # Версия ffmpeg, которую отдаст apt этой системы, - голая, без эпохи и ревизии # дистрибутива. Эпоху отрезать обязательно: у Debian это `7:5.1.6-0+deb12u1`, и # `dpkg --compare-versions` честно скажет, что 7:5.1.6 больше 6.1 - сравнивая эпохи, а # не то, что нам нужно. Ничего не печатает, если пакета в репозитории нет вовсе. # ⚠️ LC_ALL=C обязателен: фаза `locale` уже включила русскую локаль, и apt печатает # «Кандидат:» вместо «Candidate:» - разбор молча возвращал пустоту, а установка тянула # ненужный пакет ffmpeg (пойман замером: минута на пустом месте). apt_candidate_version() { # $1 - имя пакета LC_ALL=C apt-cache policy "$1" 2>/dev/null | sed -n 's/^ *Candidate: *//p' | head -1 | sed 's/^[0-9]*://; s/-.*//; s/^(none)$//' } install_packages() { log "dependencies" "зависимости" if [ "${OS_FAMILY:-linux}" = macos ]; then brew_as_invoker install "${BREW_PACKAGES[@]}" PATH="$(brew_as_invoker --prefix)/bin:$PATH" export PATH pick_python info "macOS supplies curl, bsdtar, LibreSSL and CA trust; Homebrew supplies jq, python venv and ffmpeg" \ "macOS даёт curl, bsdtar, LibreSSL и доверие CA; Homebrew даёт jq, python venv и ffmpeg" info "interpreter $PYTHON ($("$PYTHON" -c 'import sys; print(sys.version.split()[0])'))" "интерпретатор $PYTHON ($("$PYTHON" -c 'import sys; print(sys.version.split()[0])'))" return fi local want=() missing=() pkg updated=0 # Пакетный ffmpeg берём, только если он годен: он тут запасной аэродром, чтобы не # качать статическую сборку там, где системный уже свежее нижней границы (Ubuntu # 24.04 - 6.1.1). Где он заведомо стар (Debian 12 - 5.1), ставить его незачем: # статическую сборку мы всё равно возьмём, а он тянет за собой полторы сотни пакетов. # Замер на чистой Debian 12: apt с ffmpeg - 55 с, без него - 9 с, и ни один из этих # пакетов дальше не нужен. # На пустой машине списки apt ещё не скачаны и версии не видно - тогда обновляем их # прямо здесь (позже они всё равно понадобятся) и спрашиваем ещё раз. local apt_ff; apt_ff="$(apt_candidate_version ffmpeg)" if [ -z "$apt_ff" ] && ! dpkg -s ffmpeg >/dev/null 2>&1; then apt-get update -qq; updated=1 apt_ff="$(apt_candidate_version ffmpeg)" fi for pkg in "${APT_PACKAGES[@]}"; do if [ "$pkg" = ffmpeg ] && [ -n "$apt_ff" ] \ && ! dpkg -s ffmpeg >/dev/null 2>&1 \ && ! version_ge "$apt_ff" "$FFMPEG_MIN" 2>/dev/null; then info "repository has ffmpeg $apt_ff (need >= $FFMPEG_MIN) - not installing the package; using a static build" \ "в репозитории ffmpeg $apt_ff (нужно ≥ $FFMPEG_MIN) - пакет не ставлю, беру статическую сборку" continue fi want+=("$pkg") done for pkg in "${want[@]}"; do dpkg -s "$pkg" >/dev/null 2>&1 || missing+=("$pkg") done if [ ${#missing[@]} -eq 0 ]; then skip "apt packages (${want[*]})" "apt-пакеты (${want[*]})" else [ "$updated" = 1 ] || apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${missing[@]}" fi pick_python info "interpreter $PYTHON ($("$PYTHON" -c 'import sys; print(sys.version.split()[0])'))" "интерпретатор $PYTHON ($("$PYTHON" -c 'import sys; print(sys.version.split()[0])'))" } # --- 2. Пакет torrcast в собственный venv ------------------------------------ #: Venv продукта: заводим, когда его нет, и ПЕРЕСОБИРАЕМ, когда он собран питоном ниже #: нижней границы. 🔴 Второе куплено переходом на 3.12: у машины, где продукт стоял с #: 3.11, каталог venv на месте и `python` в нём запускается, так что проверка «уже есть» #: уводила установку мимо, - а pip в таком venv отказывается ставить пакет по существу #: («requires a different Python»), и обновление умирало бы на ровном месте. Спрашиваем #: не наличие каталога, а ВЕРСИЮ. Пересборка безопасна: всё, что в venv лежит, кладёт #: туда следующий же шаг (pip ставит пакет, install_cast_command переписывает команду). ensure_venv() { if [ -x "$PREFIX/venv/bin/python" ] && ! "$PREFIX/venv/bin/python" -c \ 'import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)' 2>/dev/null; then loud "venv $PREFIX/venv is built on a python below 3.12 - rebuilding it on $PYTHON" \ "venv $PREFIX/venv собран питоном ниже 3.12 - пересобираю его на $PYTHON" rm -rf "$PREFIX/venv" fi if [ ! -x "$PREFIX/venv/bin/python" ]; then install -d -m 0755 "$PREFIX" "$PYTHON" -m venv "$PREFIX/venv" else skip "venv $PREFIX/venv" "venv $PREFIX/venv" fi } # Отвечает ли индекс пакетов. Спрашиваем страницу одного пакета, а не корень индекса: # корневой листинг — десятки мегабайт, и замер упирался бы в его размер, а не в # доступность. Молчаливо: неответ здесь — штатная ветка, а не ошибка. index_alive() { # $1 — базовый URL индекса curl -fsS -m 10 -o /dev/null "${1%/}/pip/" 2>/dev/null } # Откуда pip берёт пакеты в этот заход: штатный индекс, а если из этой сети он не # отвечает — первое живое зеркало. Выбор кладётся в PIP_INDEX_URL, поэтому его видят и # сборочные окружения pip (зависимости сборки тоже качаются из индекса). Заданный # снаружи PIP_INDEX_URL не трогаем: выбор уже сделан за нас. pick_pip_index() { [ -n "${PIP_INDEX_URL:-}" ] && return 0 index_alive "$PIP_INDEX" && return 0 local mirror for mirror in "${PIP_MIRRORS[@]}"; do if index_alive "$mirror"; then export PIP_INDEX_URL="$mirror" info "⚠ PyPI is unavailable - installing through mirror ${mirror#https://}" "⚠ pypi недоступен - ставлю через зеркало ${mirror#https://}" return 0 fi done info "⚠ PyPI and its mirrors are unavailable - trying the default route" "⚠ pypi недоступен, и зеркала тоже - пробую штатным путём" } # Хватает ли pip, который принесла система, чтобы собрать наше колесо. # # 🔴 TC-966. Обновление pip на КАЖДОЙ установке стоило человеку секунд и не покупало # ничего: замер 06-09-2026 на чистом Debian 12 - штатным pip 23.0.1 пакет ставится за # 19.2 с, обновлённым pip 26.2.1 за 20.4 с, а само обновление стоит 4.9 с в тишине и # 15.6 с рядом с фоновыми закачками. Порог не выдуман: сборка идёт бэкендом hatchling по # PEP 517, а его понимает pip начиная с 21.3 - ниже этой границы обновление по-прежнему # делается, и делается молча, как делалось всегда. PIP_MIN="${TORRCAST_PIP_MIN:-21.3}" pip_new_enough() { local have have="$("$PREFIX/venv/bin/pip" --version 2>/dev/null | awk '{print $2}')" || return 1 [ -n "$have" ] || return 1 # Сравнение версий без питона: старшая пара чисел, дополненная нулями. awk -v have="$have" -v need="$PIP_MIN" 'BEGIN { split(have, h, "."); split(need, n, "."); for (i = 1; i <= 3; i++) { a = h[i] + 0; b = n[i] + 0; if (a > b) exit 0; if (a < b) exit 1; } exit 0 }' } install_torrcast() { local installed site log "torrcast package -> $PREFIX" "пакет torrcast → $PREFIX" pick_python # фаза может гоняться и в одиночку, без `packages` ensure_venv pick_pip_index site="$("$PREFIX/venv/bin/python" -P -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')" || die "venv $PREFIX/venv does not respond - there is nowhere to install the package" "venv $PREFIX/venv не отвечает - ставить пакет некуда" drop_pip_leftovers "$site" pip_new_enough || "$PREFIX/venv/bin/pip" install --quiet --upgrade pip # Первый вызов ставит зависимости, второй — САМ пакет, всегда заново. # ⚠️ Оба флага второго вызова нужны, и оба пойманы живой выкаткой: # без --force-reinstall pip видит ту же версию из pyproject.toml (от правок кода она # не меняется), говорит «Requirement already satisfied» и уходит; а с ним, но без # --no-cache-dir, он ставит СВОЁ прежнее колесо из кэша — то есть опять не наш код. # Оба раза ./install.sh рапортовал «готово», а в venv оставалась прежняя нарезка HLS, # и замеры шли по коду, которого в репе уже не было. # # 🔴 TC-966. Второй вызов нужен ровно там, где первому есть что застать: в venv уже # лежит пакет той же версии. На чистой машине заставать нечего - первый вызов ставит # наш код с нуля, а второй пересобирает то же колесо ещё раз. Замер 06-09-2026 на # чистом госте: 3.8 с из 96 с всей установки уходило на эту вторую сборку. local had_package=0 torrcast_site_dir >/dev/null 2>&1 && had_package=1 "$PREFIX/venv/bin/pip" install --quiet "$REPO_DIR" [ "$had_package" = 0 ] || "$PREFIX/venv/bin/pip" install --quiet --force-reinstall --no-deps --no-cache-dir "$REPO_DIR" install -d -m 0755 "$BIN_DIR" install_cast_command setup_cast_sudoers installed="$(torrcast_site_dir)" || die "torrcast package cannot be imported from $PREFIX/venv - installation failed" "пакет torrcast не импортируется из $PREFIX/venv - установка не состоялась" prune_torrcast "$installed" "$REPO_DIR/torrcast" # 🔴 TC-887. Загрузчик кладётся рядом с venv, потому что `cast --upgrade` зовёт ЕГО, а # не второй такой же на питоне. В колесо он не попадает: pip ставит пакеты, а это # sh-скрипт из корня репы - тем же порядком, каким сюда едут sni-shim.py и индексеры. # Без него `cast --upgrade` скажет, чем обновиться, а не промолчит: см. cli/upgrade. if [ -f "$REPO_DIR/install" ] && ! cmp -s "$REPO_DIR/install" "$PREFIX/install"; then install -m 0755 "$REPO_DIR/install" "$PREFIX/install" fi verify_torrcast "$installed" } # Команда `cast` в PATH. На Linux обёртка отдаёт собранную локаль только процессу # продукта: менять язык ssh, консоли, других пользователей и служб установщик не вправе. # На маке обычный пользователь root'ом не ходит, а показ # обязан работать под root: macOS выдаёт доступ к локальной сети «ответственному # процессу», и у неподписанного задания launchd его нет (Errno 65, не лечится ни # повтором, ни таймаутом), тогда как root свободен от TCC вовсе. Замеры живого стенда: # один и тот же щуп до телевизора проходит из ssh-сессии и из launchd-задания под # root - и не проходит из того же задания под обычным пользователем в любой области. # Побочно root решает и владение конфигом: /etc/torrcast/config.json нарочно 0600 root # (в нём apikey), и делить его по группе - значит ослаблять эту раскладку. # Поэтому на маке $BIN_DIR/cast - обёртка: не-root перезапускает себя через sudo -n # (право даёт именное правило, которое пишет :func:`setup_cast_sudoers`), а дальше всё # идёт тем же кодом, что под root доказано живьём. install_cast_command() { if [ "${OS_FAMILY:-linux}" != macos ]; then local tmp; tmp="$(mktemp /tmp/torrcast-cast.XXXXXX)" cat >"$tmp" <"$tmp" <"$tmp" if ! "$VISUDO" -cf "$tmp" >/dev/null 2>&1; then rm -f "$tmp" die "the sudoers rule for cast did not pass visudo - it was NOT installed" \ "правило sudoers для cast не прошло visudo - оно НЕ установлено" fi install -d -m 0755 "$SUDOERS_D" install -m 0440 "$tmp" "$rule_file" rm -f "$tmp" final_loud "macOS hides the local network from unsigned jobs, so cast on this Mac runs as root: $SUDO_USER is granted passwordless sudo for the single command $BIN_DIR/cast (rule $rule_file)" \ "macOS закрывает локальную сеть от неподписанных заданий, поэтому cast на этом маке работает от root: $SUDO_USER выдан беспарольный sudo ровно на одну команду $BIN_DIR/cast (правило $rule_file)" } # 🔴 TC-713. Следы оборванной установки. Снося прежний пакет, pip сперва переименовывает # его в `~...` и стирает уже после успеха; убитый на этом месте, он оставляет полную копию # прежнего кода. Сам он её не уберёт никогда - только ругается на неё «invalid # distribution» при каждом своём запуске, и с каждым обрывом таких копий становится # больше. Venv тут наш целиком, чужого в нём нет, поэтому сносим все. drop_pip_leftovers() { # $1 - каталог site-packages нашего venv local site="$1" left count=0 for left in "$site"/~*; do [ -e "$left" ] || continue rm -rf "$left" count=$((count + 1)) done [ "$count" -gt 0 ] && info "removed traces of an interrupted installation: $count" "убрано следов оборванной установки: $count" return 0 } # Каталог, из которого пакет РЕАЛЬНО импортируется. Спрашиваем сам интерпретатор, а не # складываем путь из версии: -P убирает текущий каталог из sys.path, иначе `import # torrcast`, запущенный из каталога с исходниками, нашёл бы их, а не установленный пакет. torrcast_site_dir() { "$PREFIX/venv/bin/python" -P -c \ 'import pathlib, torrcast; print(pathlib.Path(torrcast.__file__).resolve().parent)' } # Файлы установленного пакета, которым в дереве исходников пары нет. # Байт-код зовётся не как исходник (`имя.cpython-311.pyc` в соседнем `__pycache__`), # поэтому пару ему ищут по имени до первой точки и на каталог выше. stray_files() { # $1 - каталог установленного пакета, $2 - каталог исходников local pkg="$1" src="$2" rel dir stem ( cd "$pkg" || return 1 if [ "${OS_FAMILY:-linux}" = macos ]; then # BSD find не знает -printf: срезаем ./ у выдачи сами. LC_ALL=C find . -type f | sed 's|^\./||' | LC_ALL=C sort else LC_ALL=C find . -type f -printf '%P\n' | LC_ALL=C sort fi ) | while IFS= read -r rel; do case "$rel" in __pycache__/*) dir="." ;; */__pycache__/*) dir="${rel%/__pycache__/*}" ;; *) if [ ! -e "$src/$rel" ]; then printf '%s\n' "$rel"; fi continue ;; esac stem="${rel##*/}" stem="${stem%%.*}" if [ ! -e "$src/$dir/$stem.py" ]; then printf '%s\n' "$rel"; fi done } # 🔴 TC-713. Приводим установленный пакет к дереву: чего в дереве нет, того нет и здесь. # pip убирает за собой ровно то, что сам записал в RECORD, и на ровном месте с этим # справляется. Но установку можно оборвать между сносом старого и записью нового, и # тогда в site-packages остаются файлы, о которых pip больше не знает: ни повторная # установка, ни --force-reinstall их уже не тронут. Такой файл продолжает # импортироваться - и модуль, удалённый из дерева месяц назад, живёт и работает. prune_torrcast() { # $1 - каталог установленного пакета, $2 - каталог исходников local pkg="$1" src="$2" rel gone=0 while IFS= read -r rel; do rm -f "$pkg/$rel" info "removed extra package file: $rel" "лишнее в пакете, убрано: $rel" gone=$((gone + 1)) done < <(stray_files "$pkg" "$src") # Каталог, из которого унесли последний файл, оставлять нельзя: пустой каталог внутри # пакета Python считает пространством имён, и `import` по мёртвому имени состоится. find "$pkg" -mindepth 1 -depth -type d -empty -delete [ "$gone" -gt 0 ] && info "package now matches the tree: removed $gone files" "пакет приведён к дереву: убрано файлов $gone" return 0 } # Слепок дерева: «sha256 + относительный путь» на каждый файл, кроме байт-кода, порядок # фиксирован сортировкой, поэтому два каталога сравниваются построчно. Считаем ВСЕ файлы, # а не только .py: колесо везёт всё, что лежит в пакете, и лишний .json или .yml в # site-packages - такой же файл без пары в дереве, как и лишний модуль. py_manifest() { # $1 — каталог пакета torrcast ( cd "$1" || return 1 LC_ALL=C find . -name __pycache__ -prune -o -type f -print0 | if [ "${OS_FAMILY:-linux}" = macos ]; then LC_ALL=C sort -z | xargs -0 shasum -a 256 else LC_ALL=C sort -z | xargs -0r sha256sum fi ) } # Сверка «что реально лежит в venv» ↔ «что лежит в репе». Без неё установка врёт: # pip умеет отрапортовать успех, не тронув ни одного файла (см. комментарий выше), # и это ловилось только сверкой хэшей руками после каждой выкатки. Расхождение — # это провал установки, а не повод для предупреждения: дальше по скрипту нет ничего, # что чинило бы venv, а показ пойдёт по чужому коду. verify_torrcast() { # $1 — каталог установленного пакета local installed="$1" repo_side venv_side changed count sum extra [ -d "$REPO_DIR/torrcast" ] || die "there is no torrcast/ directory next to install.sh - nothing to verify" "рядом с install.sh нет каталога torrcast/ - нечего сверять" [ -d "$installed" ] || die "torrcast imports, but directory $installed is missing" "torrcast импортируется, но каталога $installed нет" # Сверка хэшей видит только пары «есть там и там»; отдельной строкой спрашиваем то, # чего в дереве нет вовсе, - включая осиротевший байт-код, которого слепок не берёт. extra="$(stray_files "$installed" "$REPO_DIR/torrcast" | tr '\n' ' ')" [ -z "$extra" ] || die "package still contains files absent from the tree: $extra" "в пакете остались файлы, которых в дереве нет: $extra" repo_side="$(py_manifest "$REPO_DIR/torrcast")" venv_side="$(py_manifest "$installed")" [ -n "$repo_side" ] || die "$REPO_DIR/torrcast contains no .py files - nothing to verify" "в $REPO_DIR/torrcast нет ни одного .py - сверять нечего" if [ "$repo_side" != "$venv_side" ]; then # Имена расходящихся файлов: строки, встречающиеся только с одной стороны. changed="$(comm -3 \ <(printf '%s\n' "$repo_side" | LC_ALL=C sort) \ <(printf '%s\n' "$venv_side" | LC_ALL=C sort) | awk '{print $NF}' | LC_ALL=C sort -u)" printf '%s\n' "$changed" | while read -r f; do [ -n "$f" ] && info "differs: ${f#./}" "расходится: ${f#./}" done info "in repository: $REPO_DIR/torrcast" "в репе: $REPO_DIR/torrcast" info "in venv: $installed" "в venv: $installed" die "venv does not match the sources: pip reported success but did not update the code" "venv не совпадает с исходниками: pip отрапортовал успех, но код не обновился" fi count="$(printf '%s\n' "$repo_side" | grep -c .)" if [ "${OS_FAMILY:-linux}" = macos ]; then sum="$(printf '%s\n' "$repo_side" | shasum -a 256 | cut -c1-12)" else sum="$(printf '%s\n' "$repo_side" | sha256sum | cut -c1-12)" fi info "venv vs repository check: $count files match (sha256 $sum)" "сверка venv ↔ репа: $count файлов совпадают (sha256 $sum)" # 🔴 TC-887. Отпечаток обязан доехать до ЭКРАНА, а не только до журнала: под заставкой # весь `info` уходит в журнал, и обновление отчиталось бы одним словом «обновлено» - # то есть ничем проверяемым. Считает его работник в своей подоболочке, поэтому едет он # тем же каналом, что и приёмник с фазами, а не переменной, которой рисовалка не увидит. ui_say S "$sum" } # --- 3. TorrServer ---------------------------------------------------------- torrserver_asset_name() { # $1 - ОС, $2 - uname -m local os arch case "$1" in linux) os=linux ;; macos) os=darwin ;; *) return 1 ;; esac case "$2" in x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; armv7l) arch=arm7 ;; *) return 1 ;; esac printf 'TorrServer-%s-%s' "$os" "$arch" } install_torrserver() { local budget place where upgraded=0 if [ "${OS_FAMILY:-linux}" = macos ]; then # launchd has no cgroup-like hard memory ceiling. Keeping TorrServer's cache # in RAM would therefore leave its measured 2x heap amplification unbounded; # use the independently sized disk budget and retain only Go's soft limit. place="disk $(ts_cache_disk)" || die "could not calculate TorrServer cache size - see the reason above" "не рассчитался размер кэша TorrServer - причина выше" else place="$(ts_cache_place)" || die "could not calculate TorrServer cache size - see the reason above" "не рассчитался размер кэша TorrServer - причина выше" fi TS_DISK="${place%% *}" TS_CACHE="${TORRCAST_TS_CACHE:-${place#* }}" if [ "$TS_DISK" = disk ]; then where="на диске ($TS_CACHE_DIR)" budget=$(( TS_DISK_MEM * 1024 * 1024 )) else where="в памяти" budget=$(( TS_CACHE * TS_MEM_OVERHEAD )) fi log "TorrServer ($TS_URL, $where cache $((TS_CACHE / 1024 / 1024)) MiB)" "TorrServer ($TS_URL, кэш $where $((TS_CACHE / 1024 / 1024)) МиБ)" install -d -m 0755 "$PREFIX/bin" "$PREFIX/torrserver" if [ "$TS_DISK" = disk ]; then install -d -m 0755 "$TS_CACHE_DIR" fi local installed="" if [ -x "$PREFIX/bin/TorrServer" ]; then installed="$("$PREFIX/bin/TorrServer" --help 2>&1)" installed="${installed%%$'\n'*}" installed="${installed#TorrServer }" fi if [ "$installed" = "$TS_VERSION" ]; then skip "TorrServer binary $installed" "бинарь TorrServer $installed" else local asset url asset="$(torrserver_asset_name "$OS_FAMILY" "$MACHINE_ARCH")" || die \ "no TorrServer build for $OS_FAMILY/$MACHINE_ARCH; see YouROK/TorrServer releases" \ "нет сборки TorrServer под $OS_FAMILY/$MACHINE_ARCH; см. релизы YouROK/TorrServer" url="$(gh_release YouROK/TorrServer "$TS_VERSION" TorrServer exact \ | jq -r --arg n "$asset" \ '.assets[]|select(.name==$n)|.browser_download_url')" || url="" # Пина нет на месте загрузки. На чистой машине ставить нечего, а взять вместо # него неизвестную версию ровно той службы, чей дефект мы ловим, - это и есть та # подмена, из-за которой версии разъехались: падаем с адресом, что чинить. Но # там, где рабочий бинарь уже стоит, установка обязана деградировать, а не # умирать: остаёмся на нём и говорим об этом вслух. if [ -z "$url" ] || [ "$url" = null ]; then [ -n "$installed" ] \ || die "pinned $asset build ($TS_VERSION) was not found; see YouROK/TorrServer releases" "не нашёл пиненную сборку $asset ($TS_VERSION); см. релизы YouROK/TorrServer" loud "TorrServer remains at $installed: pinned $TS_VERSION is absent from the download site" \ "TorrServer остаётся на $installed: пиненного $TS_VERSION на месте загрузки нет" info "update the pin in the TS_VERSION line in install.sh; versions are listed in YouROK/TorrServer releases" \ "поднять пин - строка TS_VERSION в install.sh, список версий лежит в релизах YouROK/TorrServer" else info "downloading $url" "качаю $url" fetch -o "$PREFIX/bin/TorrServer.new" "$url" || die "could not download TorrServer: $url" "не скачался TorrServer: $url" chmod +x "$PREFIX/bin/TorrServer.new" mv "$PREFIX/bin/TorrServer.new" "$PREFIX/bin/TorrServer" [ -z "$installed" ] || info "TorrServer updated: $installed -> $TS_VERSION" "TorrServer обновлён: $installed -> $TS_VERSION" upgraded=1 fi fi # Три потолка вокруг кэша, и ни один не для красоты. # GOGC - рабочий: он и держит перерасход над живым кэшем у делителя, которым кэш # размерен (TS_MEM_OVERHEAD). Без него Go растит кучу вдвое над кэшем, то есть ровно # до бюджета службы, и на памяти машины не остаётся ничего: замер на 8 ГиБ показал # 5911 МиБ у службы при кэше 3.13 ГиБ и 99.2 % потолка у машины. # GOMEMLIMIT - аварийный: он не запрещает расти, а заставляет сборщик работать # непрерывно у самой черты. Ставится по бюджету службы: рабочий режим до него не # доходит, а кэш, выросший сам, упирается в него, а не в память машины. С кэшем на # диске бюджет не зависит от размера кэша вовсе (замер: 104 МиБ при 3.2 ГиБ и 111 МиБ # при 12 ГиБ), поэтому там он фиксированный - TS_DISK_MEM. # MemoryMax - жёсткий, на случай, когда мягких не хватило (чужая утечка, раздача с # гигантским куском). Служба тогда умирает и поднимается заново # (Restart=on-failure): показ прервётся, но машина останется живой - а вставший колом # хозяин без ssh это ровно то, что мы чиним. [ "$upgraded" = 0 ] || stop_service torrserver "$PREFIX/bin/TorrServer" local memory_knobs="Environment=GOGC=$TS_GOGC Environment=GOMEMLIMIT=${budget}B" if [ "${OS_FAMILY:-linux}" = macos ]; then loud "launchd has no enforceable memory ceiling; TorrServer uses a disk cache and only the soft GOMEMLIMIT guard" \ "у launchd нет исполняемого жёсткого потолка памяти; TorrServer использует кэш на диске и только мягкий GOMEMLIMIT" else memory_knobs="$memory_knobs MemoryMax=$(( budget + 256 * 1024 * 1024 )) MemorySwapMax=0" fi run_service torrserver "TorrServer для torrcast" \ "$PREFIX/bin/TorrServer --port $TS_PORT --ip $TS_HOST --path $PREFIX/torrserver" \ "$memory_knobs" wait_http "$TS_URL/echo" 60 || die "TorrServer did not start at $TS_URL" "TorrServer не поднялся на $TS_URL" # Кэш там, где его больше поместилось, публичные ретрекеры в magnet'ы, DHT и PEX # включены. # RemoveCacheOnDrop выключен: прогретый кэш обязан переживать перезапуск службы, # иначе каждая смерть службы начинала бы показ холодным. Место на общем с прогревом # разделе стережёт расчёт `ts_cache_disk` (бюджет прогрева и неприкосновенный запас # удержаны до выбора размера), а внутри своего размера кэш вытесняется сам. # ConnectionsLimit: дефолтные 25 соединений — это потолок скорости на ПЕРВЫХ секундах, # когда пиры ещё разбираются, кто что отдаёт, а нам нужны и хвост файла (Cues), и # начало (первый сегмент) прямо сейчас. Холодный старт упирается ровно в это место, # поэтому потолок поднят. local sets disk path if [ "$TS_DISK" = disk ]; then disk=true; path="$TS_CACHE_DIR"; else disk=false; path=""; fi sets="$(curl -fsS -X POST "$TS_URL/settings" -d '{"action":"get"}' \ | jq -c --argjson c "$TS_CACHE" --argjson d "$disk" --arg p "$path" \ '.CacheSize=$c|.UseDisk=$d|.TorrentsSavePath=$p|.RemoveCacheOnDrop=false |.RetrackersMode=1 |.DisableDHT=false|.DisablePEX=false|.EnableDLNA=false |.ConnectionsLimit=100')" curl -fsS -X POST "$TS_URL/settings" -H 'Content-Type: application/json' \ -d "{\"action\":\"set\",\"sets\":$sets}" >/dev/null if [ "$TS_DISK" = disk ]; then if [ "${OS_FAMILY:-linux}" = macos ]; then info "$((TS_CACHE / 1024 / 1024)) MiB disk cache ($TS_CACHE_DIR), survives service restarts; $(( $(disk_free "$TS_CACHE_DIR") / 1024 / 1024 )) MiB free, soft GOMEMLIMIT $((budget / 1024 / 1024)) MiB, no hard service limit, retrackers enabled" "кэш $((TS_CACHE / 1024 / 1024)) МиБ на диске ($TS_CACHE_DIR), переживает перезапуск службы, свободно на разделе $(( $(disk_free "$TS_CACHE_DIR") / 1024 / 1024 )) МиБ, мягкий GOMEMLIMIT $((budget / 1024 / 1024)) МиБ, жёсткого потолка службы нет, ретрекеры включены" else info "$((TS_CACHE / 1024 / 1024)) MiB disk cache ($TS_CACHE_DIR), survives service restarts; $(( $(disk_free "$TS_CACHE_DIR") / 1024 / 1024 )) MiB free, service limit $((budget / 1024 / 1024)) MiB, retrackers enabled" "кэш $((TS_CACHE / 1024 / 1024)) МиБ на диске ($TS_CACHE_DIR), переживает перезапуск службы, свободно на разделе $(( $(disk_free "$TS_CACHE_DIR") / 1024 / 1024 )) МиБ, потолок службы $((budget / 1024 / 1024)) МиБ, ретрекеры включены" fi else info "$((TS_CACHE / 1024 / 1024)) MiB memory cache with $(( $(host_memory) / 1024 / 1024 )) MiB host memory, service limit $((budget / 1024 / 1024)) MiB, retrackers enabled" "кэш $((TS_CACHE / 1024 / 1024)) МиБ в памяти при $(( $(host_memory) / 1024 / 1024 )) МиБ памяти машины, потолок службы $((budget / 1024 / 1024)) МиБ, ретрекеры включены" fi } # --- 3.5. Источники: проверяем, что доступно, и обходим только то, что бито ---- # # В разных сетях доступно разное: то, что у одного провайдера открыто, у другого режется # по SNI. Поэтому ничего не обходится «на всякий случай»: сначала замер, обход ставится # только если источник реально не отвечает. pinned() { # $1 имя - прибито ли уже к 127.0.0.1 grep -qE "^127\.0\.0\.1[[:space:]]+$1(\$|[[:space:]])" "$HOSTS_FILE" } hosts_pin() { # $1 имя — прибить к петле тем же редактором, что использует шим local changed addresses="127.0.0.1" pick_python if [ "${OS_FAMILY:-linux}" = macos ]; then addresses="127.0.0.1,::1"; fi changed="$(TORRCAST_HOSTS="$HOSTS_FILE" TORRCAST_PIN_ADDRESSES="$addresses" \ TORRCAST_FLUSH_DNS="${TORRCAST_FLUSH_DNS:-${OS_FAMILY:-linux}}" \ "$PYTHON" "$REPO_DIR/scripts/sni-shim.py" --pin "$1")" if [ "$changed" = 0 ]; then skip "$HOSTS_FILE: $1" "$HOSTS_FILE: $1" else info "$HOSTS_FILE: $1 -> $addresses" "$HOSTS_FILE: $1 → $addresses" fi } # Адрес origin'а спрашиваем у DNS сами, тем же приёмом, что и шим (его `Resolver`). # 🔴 Иначе проверять нечего: имя трекера может быть прибито к шиму, и проба ушла бы # СКВОЗЬ него, всегда отвечая «всё хорошо» - ровно поэтому прежняя установка и не # пересматривала однажды принятое решение. # Печатает по адресу на каждое семейство: клиент выбирает дорогу сам, и щупать одну # лишь IPv4, когда он первым берёт IPv6, значит мерить не то. Замер: yts.gg по IPv4 # отдавал ответ целиком за 0.3 с, а Prowlarr в тот же миг получал по IPv6 обрыв на # 16401 Б - и именно этим обходом шим его и лечит (сам он ходит только по IPv4). shim_resolve() { # $1 имя; печатает адреса origin'а через пробел (пусто - не разобрали) pick_python "$PYTHON" "$REPO_DIR/scripts/sni-shim.py" --resolve "$1" 2>/dev/null | tr '\n' ' ' } catalog_available() { # каталог напрямую, в обход нашей возможной строки в hosts local host path url="${PL_DEFS_URL#https://}" addresses host="${url%%/*}" path="/${url#*/}" addresses="$(shim_resolve "$host")" probe_every "$host" "$path" "" "$addresses" } prepare_definitions() { if catalog_available; then info "Prowlarr indexer catalog is available - it will fetch definitions itself" "каталог индексеров Prowlarr доступен - он возьмёт определения сам" else info "⚠ Prowlarr indexer catalog is unavailable - fetching definitions from GitHub" "⚠ каталог индексеров Prowlarr недоступен - определения возьмём с GitHub" hosts_pin indexers.prowlarr.com job_start defs seed_definitions fi } # Отвечает ли имя целиком по КАЖДОМУ своему адресу. Один больной край - это молчащий # индексер через раз, а цена такой ошибки несимметрична (см. `check_sources`). probe_every() { # $1 имя, $2 путь, $3 тело POST, $4 адреса через пробел local address seen=0 for address in $4; do probe_whole "$1" "$2" "$3" "$address" || return 1 seen=1 done [ "$seen" = 1 ] } # Отвечает ли имя ЦЕЛИКОМ. Успех - curl дочитал ответ до конца: обрыв посреди тела он # считает ошибкой, даже если заголовки были 200. Ровно так троттлинг и выглядит. #: 🔴 TC-235. Проба решает, идём ли к имени напрямую или через шим, и решать это обязан #: ПРИЗНАК ПОЛОМКИ, а не секундомер. Болезнь выглядит так: заголовки пришли, тело встало #: и больше не двигается никогда. Здоровье выглядит иначе даже на занятом канале: байты #: идут, пусть и медленно. Поэтому мерим не «сколько всего заняло», а «сколько подряд #: ничего не приезжает»: `--speed-time` секунд ниже `--speed-limit` байт в секунду - это #: обрыв, и проба честно падает; всё, что тянется, но тянется, - живо и ждётся дальше. #: #: Прежний плоский потолок 8 с (TC-214) резал по времени и потому врал в обе стороны: #: замер на подставном origin'е (200 КБ по 10 КБ/с - медленно, но целиком) - с потолком #: 8 с проба падала с rc=28 и живое имя уезжало через шим; с признаком простоя она же #: доходит до конца за 19 с и rc=0. На больном имени (тело встало после 16 КБ) падают #: обе, разница в цене - 8 с против 11. #: Потолок остаётся, но уже как страховка от бесконечной струйки, а не как судья. PROBE_TIMEOUT="${TORRCAST_PROBE_TIMEOUT:-25}" #: Сколько секунд подряд можно ничего не получать, прежде чем счесть тело оборванным. #: Пять - это заметно больше любой заминки занятого канала и заметно меньше терпения #: человека; на трёх ложные тревоги возвращаются, на десяти фаза дорожает вдвое. PROBE_STALL="${TORRCAST_PROBE_STALL:-5}" #: Ниже скольких байт в секунду поток считается стоящим. Килобайт - это в сотни раз #: меньше самого медленного здорового ответа и заведомо больше нуля, который отдаёт #: обрыв. PROBE_FLOOR="${TORRCAST_PROBE_FLOOR:-1024}" #: Сколько непрерывно может не быть процесса шима, прежде чем отдельный сторож снимет #: его строки из hosts. Штатный RestartSec=5 укладывается с двукратным запасом; настоящая #: смерть всё равно освобождает прямую дорогу, как требует аренда TC-267. SHIM_DEAD_GRACE="${TORRCAST_SHIM_DEAD_GRACE:-12}" SHIM_PID="$SHIM_DIR/shim.pid" probe_whole() { # $1 имя, $2 путь, $3 тело POST (пусто - GET), $4 адрес origin'а (пусто - как ляжет DNS) # Форма с `+`: bash 3.2 (macOS) под set -u считает пустой массив unbound. local pin=() [ -n "${4:-}" ] && pin=(--resolve "$1:443:$4") if [ -n "$3" ]; then curl -fsS -m "$PROBE_TIMEOUT" --speed-time "$PROBE_STALL" --speed-limit "$PROBE_FLOOR" \ -o /dev/null -A "$UA" ${pin[@]+"${pin[@]}"} -H 'Content-Type: application/json' \ -X POST -d "$3" "https://$1$2" 2>/dev/null else curl -fsS -m "$PROBE_TIMEOUT" --speed-time "$PROBE_STALL" --speed-limit "$PROBE_FLOOR" \ -o /dev/null -A "$UA" ${pin[@]+"${pin[@]}"} "https://$1$2" 2>/dev/null fi } # Прежний шим умел ровно один трекер и звался по нему. Общий садится на тот же порт, # так что старую службу гасим - иначе он просто не встанет. retire_old_shim() { [ -e /etc/systemd/system/knaben-shim.service ] || [ -d /etc/knaben-shim ] || return 0 systemctl disable --now knaben-shim.service >/dev/null 2>&1 || true rm -f /etc/systemd/system/knaben-shim.service /usr/local/share/ca-certificates/knaben-shim.crt rm -rf /etc/knaben-shim systemctl daemon-reload >/dev/null 2>&1 || true update-ca-certificates --fresh >/dev/null 2>&1 || true info "old single-host shim removed - the shared shim replaces it" "прежний одиночный шим убран - его место занимает общий" } install_shim_trust() { # $1 - доверенный корень SNI-шима if [ "${OS_FAMILY:-linux}" = macos ]; then # На macOS доверенный корень НЕ ставится вовсе: ни системный, ни # пользовательский домен keychain не принимает его из неинтерактивной сессии # (оба отказа SecTrustSettings сняты живьём), а просить человека доделать # установку руками продукт не вправе. Вместо доверия Prowlarr на этой # платформе отпускает проверку сертификата до шима (см. install_prowlarr), и # это ничего не снимает со сквозной проверки: на обходных маршрутах шим сам # спрашивает источник по IP без SNI и его серт не проверяет # (_plain_context в sni-shim.py) - ни на одной платформе. loud "the shim root is not trusted on macOS (the keychain refuses unattended trust); Prowlarr skips certificate checks up to the shim only - see the Prowlarr phase" \ "корень шима на macOS не становится доверенным (keychain не принимает его без рук); Prowlarr снимает проверку серта только до шима - см. фазу Prowlarr" return 0 fi install -m 0644 "$1" /usr/local/share/ca-certificates/torrcast-shim.crt update-ca-certificates >/dev/null 2>&1 } setup_shim() { # $1 - имена, которые ведём через шим (через запятую), дальше маршруты имя=кандидат[,…] local pins="$1"; shift local routes=("$@") spec spec_host spec_path spec_body sans="" changed=0 probes="" install -d -m 0755 "$SHIM_DIR" pick_python # фаза может гоняться и в одиночку, без `packages` [ -n "${TORRCAST_NO_SYSTEMD:-}" ] || retire_old_shim # Код шима приезжает из репы на КАЖДОМ заходе (деплой только репа→прод). Изменился # он, набор имён или маршруты - службу гасим: юнит-то прежний, и сама по себе она # осталась бы на старом. if cmp -s "$REPO_DIR/scripts/sni-shim.py" "$SHIM_DIR/sni-shim.py"; then skip "shim code $SHIM_DIR/sni-shim.py" "код шима $SHIM_DIR/sni-shim.py" else install -m 0755 "$REPO_DIR/scripts/sni-shim.py" "$SHIM_DIR/sni-shim.py" info "shim code updated from the repository" "код шима обновлён из репы" changed=1 fi # Серт один на все имена сразу: перевыпускаем, когда набор имён изменился. for spec in "${routes[@]}"; do sans="$sans${sans:+,}DNS:${spec%%=*}"; done if [ -s "$SHIM_DIR/shim.crt" ] && [ "$sans" = "$(cat "$SHIM_DIR/names" 2>/dev/null)" ]; then skip "shim certificate for ${#routes[@]} names" "серт шима на ${#routes[@]} имён" else openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ -keyout "$SHIM_DIR/shim.key" -out "$SHIM_DIR/shim.crt" \ -subj "/CN=${routes[0]%%=*}" -addext "subjectAltName=$sans" 2>/dev/null chmod 600 "$SHIM_DIR/shim.key" printf '%s\n' "$sans" >"$SHIM_DIR/names" info "shim certificate issued for: ${routes[*]%%=*}" "серт шима выпущен на: ${routes[*]%%=*}" changed=1 fi # Prowlarr — .NET, и доверяет он системному хранилищу; своего для процесса ему не # задать (SSL_CERT_FILE он игнорирует — проверено). Ключ остаётся доступным только root. install_shim_trust "$SHIM_DIR/shim.crt" [ "$(cat "$SHIM_DIR/routes" 2>/dev/null)" = "$(printf '%s\n' "${routes[@]}")" ] || changed=1 printf '%s\n' "${routes[@]}" >"$SHIM_DIR/routes" # Чем шим перещупывает источники (:data:`SHIMS` без кандидатов). Файлом, а не строкой # запуска: в теле пробы живут кавычки, а строку юнита systemd разбирает по-своему. # Тем же приёмом - кому постраничный обход и какого размера страница (5-е поле). local pages="" for spec in "${SHIMS[@]}"; do IFS='|' read -r spec_host spec_path spec_body _ spec_page <<<"$spec" probes="$probes$spec_host|$spec_path|$spec_body"$'\n' [ -n "${spec_page:-}" ] && pages="$pages$spec_host|$spec_page"$'\n' done [ "$(cat "$SHIM_DIR/probes" 2>/dev/null)" = "${probes%$'\n'}" ] || changed=1 printf '%s' "$probes" >"$SHIM_DIR/probes" [ "$(cat "$SHIM_DIR/pages" 2>/dev/null)" = "${pages%$'\n'}" ] || changed=1 printf '%s' "$pages" >"$SHIM_DIR/pages" # 🔴 TC-267/TC-323. Строки в /etc/hosts ставит шим, а снимает отдельный сторож, # только если процесса непрерывно нет дольше SHIM_DEAD_GRACE. Сам процесс не вправе # снимать их на SIGTERM: короткий штатный рестарт иначе выглядит для Prowlarr как # одновременный отказ всех источников. # Прибитое имя ведёт на 127.0.0.1, и пока шима там нет, трекера нет вовсе; аренда же # кончается вместе со службой, и трекер остаётся хотя бы со своим прямым путём. local knobs; knobs="Environment=TORRCAST_HOSTS=$HOSTS_FILE Environment=TORRCAST_SHIM_PID=$SHIM_PID Environment=TORRCAST_ROUTE_PROBES=$SHIM_DIR/probes Environment=TORRCAST_ROUTE_PAGES=$SHIM_DIR/pages Environment=TORRCAST_ROUTE_PINNED=$pins Environment=TORRCAST_ROUTE_EVERY=$ROUTE_EVERY Environment=TORRCAST_PROBE_TIMEOUT=$PROBE_TIMEOUT Environment=TORRCAST_PROBE_STALL=$PROBE_STALL Environment=TORRCAST_PROBE_FLOOR=$PROBE_FLOOR Environment=TORRCAST_PROBE_UA=$UA Sockets=torrcast-shim.socket" if [ "${OS_FAMILY:-linux}" = macos ]; then # Python has no standard binding for launch_activate_socket. Let the shim bind # its own port: launchd still restarts it, but connections see refusal during # that restart instead of waiting in a launchd-owned socket backlog. knobs="${knobs%$'\n'Sockets=torrcast-shim.socket} Environment=TORRCAST_FLUSH_DNS=macos Environment=TORRCAST_DNS_FALLBACK=1.1.1.1,9.9.9.9 Environment=TORRCAST_PIN_ADDRESSES=127.0.0.1,::1 Environment=TORRCAST_LISTEN_IPV6=1" info "the macOS shim owns its listening port; a restart can briefly refuse connections (no socket activation)" \ "на macOS порт слушает сам шим; при перезапуске возможен краткий отказ соединений (без socket activation)" fi # Маска - начало строки запуска, без маршрутов: у живого процесса они прежние, а # погасить надо именно его. if [ "$changed" = 1 ]; then stop_service torrcast-shim "$PYTHON $SHIM_DIR/sni-shim.py" stop_service torrcast-shim-guard "$PYTHON $SHIM_DIR/sni-shim.py --guard" fi # Socket-unit владеет портом независимо от процесса: во время рестарта входящие # соединения ждут новый шим в backlog, а не получают Connection refused. if [ -z "${TORRCAST_NO_SYSTEMD:-}" ] && [ "${OS_FAMILY:-linux}" != macos ]; then local socket_unit=/etc/systemd/system/torrcast-shim.socket socket_body socket_body="[Unit] Description=Сокет TLS-шима [Socket] ListenStream=127.0.0.1:$SHIM_PORT NoDelay=true [Install] WantedBy=sockets.target" if [ ! -f "$socket_unit" ] || [ "$(cat "$socket_unit")" != "$socket_body" ]; then stop_service torrcast-shim "$PYTHON $SHIM_DIR/sni-shim.py" printf '%s\n' "$socket_body" >"$socket_unit" systemctl daemon-reload # daemon-reload подхватывает новое тело юнита, но не трогает уже открытый # слушатель: переприбитый адрес (TORRCAST_SHIM_PORT) подхватывает только # перезапуск. На неактивном сокете try-restart - пустая ходка с нулём. systemctl try-restart torrcast-shim.socket fi fi run_service torrcast-shim-guard "Сторож аренды имён TLS-шима" \ "$PYTHON $SHIM_DIR/sni-shim.py --guard $SHIM_PID $SHIM_DEAD_GRACE ${routes[*]%%=*}" \ "Environment=TORRCAST_HOSTS=$HOSTS_FILE" run_service torrcast-shim "TLS-шим для трекеров, чьё имя не проходит по SNI" \ "$PYTHON $SHIM_DIR/sni-shim.py $SHIM_DIR/shim.crt $SHIM_DIR/shim.key $SHIM_PORT ${routes[*]}" \ "$knobs" # Сокет поднимает сам старт службы выше: Sockets= в её юните тянет socket-unit # первым, и слушатель доезжает до процесса через LISTEN_FDS (`_activated_socket` # в sni-shim.py) - замер на чистой машине: сокет слушает уже на ПЕРВОМ заходе, # двумя секундами позже старта службы. Эта строка - не запуск, а страховка: # держит автозапуск и поднимет сокет, если его остановили снаружи. Юнит службы к # этому месту на диске, поэтому «Socket service not loaded, refusing» здесь # невозможен, и стража не нужна: падение может значить только чужой процесс на # порту, а такое проглатывать нельзя. if [ -z "${TORRCAST_NO_SYSTEMD:-}" ] && [ "${OS_FAMILY:-linux}" != macos ]; then systemctl enable --now torrcast-shim.socket fi } # Пробы независимы, а каждая ждёт СВОЙ таймаут - последовательно это набегало в минуту # с лишним чистого ожидания на пустом месте. Гоняем их разом, а ответы разбираем потом и # в исходном порядке: параллельность не должна превращать журнал установки в # чересполосицу. Печатает каталог с файлами «имя» → код возврата. #: Сколько раз проба через уже поднятый шим повторяется, прежде чем признать его молчание. SHIM_PROBE_TRIES=12 #: ⚠️ Потолок на ВСЕ повторы одной пробы. Лестница заведена под «служба только что #: поднята, сокета может ещё не быть» - такая проба падает мгновенно, и десяток повторов #: стоит доли секунды. Но повторять стоит и провал по таймауту: рядом качаются соседи, #: канал занят, и живой трекер не поспевает ответить (замер: трое из четырёх названы #: молчунами, и все трое сразу после этого исправно отдавали раздачи). Цена такой #: терпимости - вот этот потолок: без него лестница стоила 204 секунды ожидания при том #: же итоге, и стоила их человеку. Теперь она живёт в догреве, и не ждёт её никто. SHIM_PROBE_BUDGET=45 probe_all() { # $1 - `direct` (одна проба мимо hosts) либо `through` (сквозь шим, до ответа); дальше строки SHIMS local how="$1"; shift local dir spec host path body ups at retry=0 pids=() [ "$how" = through ] && retry=1 dir="$(mktemp -d)" for spec in "$@"; do IFS='|' read -r host path body ups _ <<<"$spec" # Адрес берём до развилки: у `through` его быть не должно вовсе (там вся соль в # том, чтобы пойти именно сквозь прибитое имя), у `direct` - обязан. at="" [ "$how" = direct ] && at="$(shim_resolve "$host")" # Код возврата уводим в файл, а сама подоболочка завершается успешно: под # `set -e` упавшая фоновая проба уронила бы установку на `wait`. ( rc=1 i=0 began="$SECONDS" while :; do if [ "$how" = direct ]; then if probe_every "$host" "$path" "$body" "$at"; then rc=0; fi break fi if probe_whole "$host" "$path" "$body"; then rc=0; break; fi i=$((i + 1)) { [ "$retry" = 1 ] && [ "$i" -lt "$SHIM_PROBE_TRIES" ] \ && [ $((SECONDS - began)) -lt "$SHIM_PROBE_BUDGET" ]; } || break sleep 1 done printf '%s' "$rc" >"$dir/$host" true ) & pids+=("$!") done # Ждём поимённо: в песочнице рядом живут фоновые «службы», и голый `wait` завис бы # на них до скончания века. wait "${pids[@]}" printf '%s' "$dir" } # Замер и обход для тех трекеров, чьё имя может не пройти по TLS (:data:`SHIMS`). check_sources() { log "sources available from this network" "источники: что доступно из этой сети" local spec host path body ups routes=() pins=() probes="" # 🔴 TC-260. Спрашиваем ВСЕХ и каждый раз заново. Прежде прибитое имя проверять не # ходили вовсе («уже за шимом - маршрут остаётся»), и однажды принятое решение жило # вечно, хотя канал живёт часами: замер - в 19:00 имя рвалось всегда, часом позже ни # разу. Проба идёт мимо `/etc/hosts` (:func:`shim_resolve`), поэтому прибитое имя ей # больше не мешает, а сквозь шим она не уходит. probes="$(probe_all direct "${SHIMS[@]}")" for spec in "${SHIMS[@]}"; do IFS='|' read -r host path body ups _ <<<"$spec" # Маршрут заводим КАЖДОМУ, даже здоровому: шим ему пока не нужен, но перерешать # это - его же работа, и без маршрута ему нечем будет подхватить имя, когда канал # начнёт его резать. routes+=("$host=$ups") if [ "$(cat "$probes/$host" 2>/dev/null)" != 0 ]; then info "⚠ $host returns an incomplete response (cut by SNI name) - routing through the shim" "⚠ $host отдаёт ответ не целиком (режется по имени в SNI) - веду через шим" pins+=("$host") elif pinned "$host"; then # 🔴 Одной удачной пробы мало, чтобы снять уже стоящий обход: цена ошибки # несимметрична. Лишний обход здорового стоит местного хопа, а снятый обход с # больного - молчащего индексера до следующей установки, и молчит он не # словами, а пустой выдачей. Поэтому обход снимает только фоновая # перепроверка, и лишь когда имя ответит целиком несколько раз подряд. info "$host responds by name - the bypass will be removed after confirmation" "$host отвечает по имени - обход снимется сам, если это подтвердится" pins+=("$host") else info "$host returns a complete response - routing directly" "$host отвечает целиком - веду напрямую" fi done if [ -n "$probes" ]; then rm -rf "$probes"; fi if [ "${#pins[@]}" -eq 0 ]; then info "all trackers respond by name - keeping the shim ready; it will recheck automatically" "все трекеры отвечают по имени - шим держим наготове, он перепроверит сам" fi setup_shim "$(IFS=,; printf '%s' ${pins[@]+"${pins[*]}"})" "${routes[@]}" # «Шим поднят» и «через него отвечает» - разные утверждения, и второе проверяется # второй пробой: имя уже прибито к шиму, поэтому та же проба идёт сквозь него. # 🔴 Но это ОТЧЁТ, а не условие: маршрут уже стоит, и от результата этой пробы он не # меняется. Держать на ней человека нечем - она уезжает в догрев. Заодно уходит и # ложная тревога: пока рядом качаются соседи, канал занят, проба не поспевает в свой # таймаут, и установка сообщала «не отвечает и через шим» про живой трекер (замер: # трое из четырёх, и все трое сразу после этого исправно отдавали раздачи). local through=() for spec in "${SHIMS[@]}"; do IFS='|' read -r host path body ups _ <<<"$spec" printf '%s\n' ${pins[@]+"${pins[@]}"} | grep -qx "$host" || continue through+=("$spec") done if [ "${#through[@]}" -gt 0 ]; then late_run "checking trackers through the shim" "проверка трекеров через шим" verify_shims "${through[@]}" fi } # Отвечают ли трекеры через уже поднятый шим. Живёт в догреве (см. :func:`check_sources`), # и потому может позволить себе лестницу повторов: держать ею установку больше не нужно. # 🔴 TC-313. Имена в /etc/hosts прибивает сам шим, и делает это АСИНХРОННО старту службы: # служба уже «active», а строк в файле ещё нет. Проба, ушедшая в этот зазор, идёт ПРЯМЫМ # путём, а строка «через шим отвечает» говорила бы про не тот путь. Поэтому сперва ждём # прибития (потолок ниже - страховка, не норма: служба встаёт за секунды, а ждёт это # только догрев, не человек), а не дождались - честно говорим, что проверяли прямой путь. #: Сколько секунд ждём, пока шим прибьёт имена, прежде чем щупать прямой путь. SHIM_PIN_WAIT="${TORRCAST_SHIM_PIN_WAIT:-20}" verify_shims() { # $@ - строки SHIMS тех, кого ведём через шим local spec host path body ups probes waited=0 missing while [ "$waited" -lt "$SHIM_PIN_WAIT" ]; do missing=0 for spec in "$@"; do IFS='|' read -r host path body ups _ <<<"$spec" pinned "$host" || { missing=1; break; } done [ "$missing" = 0 ] && break sleep 1 waited=$((waited + 1)) done probes="$(probe_all through "$@")" for spec in "$@"; do IFS='|' read -r host path body ups _ <<<"$spec" if pinned "$host"; then if [ "$(cat "$probes/$host" 2>/dev/null)" = 0 ]; then info "$host returns a complete response through the shim" "через шим $host отвечает целиком" else info "⚠ $host does not respond through the shim either - its indexer may remain empty" "⚠ $host не отвечает и через шим - его индексер может остаться пустым" fi elif [ "$(cat "$probes/$host" 2>/dev/null)" = 0 ]; then info "$host responds completely over the direct route - the shim has not pinned the name yet" "$host отвечает целиком напрямую - шим имя ещё не прибил, проверял прямой путь" else info "⚠ $host does not respond directly and the shim has not pinned it yet - its indexer may remain empty" "⚠ $host не отвечает напрямую, и шим его ещё не прибил - его индексер может остаться пустым" fi done rm -rf "$probes" } # Определения индексеров (Cardigann) - из репы Prowlarr/Indexers на GitHub. # ⚠️ Класть ПЛОСКО в корень `Definitions/`: листинг Prowlarr читает только верхний # уровень (SearchOption.TopDirectoryOnly), и `Definitions/v11/rutor.yml` не виден. # Рестарт не нужен — схема пересобирается на каждый запрос. seed_definitions() { local dir="$PREFIX/prowlarr-data/Definitions" install -d -m 0755 "$dir" install -m 0644 "$REPO_DIR/scripts/anilibria.yml" "$dir/anilibria.yml" install -m 0644 "$REPO_DIR/scripts/jacred.yml" "$dir/jacred.yml" if [ "$(find "$dir" -maxdepth 1 -name '*.yml' 2>/dev/null | wc -l)" -gt 100 ]; then skip "indexer definitions ($(find "$dir" -maxdepth 1 -name '*.yml' | wc -l))" "определения индексеров ($(find "$dir" -maxdepth 1 -name '*.yml' | wc -l) шт.)" return fi log "indexer definitions from GitHub" "определения индексеров с GitHub" local tmp; tmp="$(mktemp -d)" # 🔴 TC-989. Распаковываем архив ЦЕЛИКОМ, а нужное отбирает `find` строкой ниже - # он это и так делал. Отбор шаблоном самим tar'ом непереносим в обе стороны: на # маке tar - это bsdtar, и он падает целиком («Option --wildcards is not # supported»), а без ключа тот же шаблон падает уже у GNU tar («Используйте # --wildcards ... Не найден в архиве», код 2). Замер цены на том же архиве: # 585 файлов и 5.1 МБ против 546 и 4.5 МБ - лишние 39 файлов это README, # .github и определения прежних схем, и все они уезжают с `rm -rf` ниже. # Скачивание и распаковка разведены НАМЕРЕННО: одна ветка на двоих говорила # «определения не скачались» там, где скачалось всё и упала распаковка, и уводила # искать сеть. Человек при этом получал 3 индексера вместо 7. if ! fetch -o "$tmp/defs.tar.gz" "$DEFS_TARBALL"; then info "⚠ definitions could not be downloaded - only built-in indexers remain" "⚠ определения не скачались - останутся только встроенные индексеры" elif ! tar -xzf "$tmp/defs.tar.gz" -C "$tmp"; then info "⚠ the definitions archive did not unpack - only built-in indexers remain" "⚠ архив определений не распаковался - останутся только встроенные индексеры" else find "$tmp" -path '*/definitions/v11/*.yml' -exec install -m 0644 {} "$dir/" \; info "installed $(find "$dir" -maxdepth 1 -name '*.yml' | wc -l) definitions" "разложено $(find "$dir" -maxdepth 1 -name '*.yml' | wc -l) определений" fi rm -rf "$tmp" } #: 🔴 TC-232. Сколько картин просим у YTS за один запрос. Определение просит 50, и это #: ровно тот размер, на котором канал рвёт ответ: замер по широким словам («the», «man», #: «love», «war», «star») - сжатая выдача приезжала на 14.6-17.8 КБ и вставала НАВСЕГДА, #: то есть при пороге обрыва около 15 КБ YTS живёт на самой границе, а на широком запросе #: не отдаёт вообще ничего. Десять картин - это 3.0-4.8 КБ на тех же словах, троекратный #: запас к самому низкому замеренному порогу, и ответ приходит за 0.83-0.88 с. #: Полноты это не отнимает, а прибавляет: в окно обрыва широкий запрос даёт от YTS НОЛЬ #: строк (тело не доезжает), а с подрезкой - десять картин; узкий запрос («матрица» - #: 8 картин, «oppenheimer» - 3) и так укладывается целиком. Окно это приходит и уходит #: само: в один час подряд рвались все широкие запросы, часом позже те же самые приезжали #: целиком - потому и «запас», а не «сегодня влезло». #: Второе направление карточки закрыто замером и не взято: их же новый адрес #: `movies-api.accel.li` режется точно так же (тот же запрос, 17.4 КБ и обрыв). YTS_LIMIT="${TORRCAST_YTS_LIMIT:-10}" # Подрезать выдачу YTS в РАЗЛОЖЕННОМ НАМИ определении. Другой ручки нет: размер выдачи # зашит в определение Cardigann (`limit: 50` в inputs), в настройках индексера его нет - # там только адрес API. Правим свою копию, а не чужую репу; строка не нашлась - говорим # об этом и оставляем как есть. # ⚠️ Определения раскладываем мы не всегда: когда каталог Prowlarr доступен, он берёт их # сам, и подрезать становится нечего. Беда при этом уходит вместе с ручкой: канал, который # рвёт выдачу YTS, точно так же рвёт и каталог Prowlarr - за определениями мы и идём # только с него. trim_yts() { # $1 - каталог с определениями; 0 - подрезали сейчас, 1 - менять нечего local file="$1/yts.yml" if [ ! -f "$file" ]; then info "⚠ yts definition is absent from disk - cannot limit its results" "⚠ определения yts нет на диске - выдачу подрезать нечем" elif grep -q "^ *limit: $YTS_LIMIT\$" "$file"; then skip "yts results limited to $YTS_LIMIT titles" "выдача yts подрезана до $YTS_LIMIT картин" elif grep -q '^ *limit: 50$' "$file"; then sed_in_place "s/^\\( *\\)limit: 50\$/\\1limit: $YTS_LIMIT/" "$file" info "yts results limited to $YTS_LIMIT titles - the full response exceeds the channel limit" "выдача yts подрезана до $YTS_LIMIT картин - полную канал рвёт по объёму" return 0 else info "⚠ yts definition has no 'limit: 50' line - nothing to limit" "⚠ в определении yts не нашлось строки «limit: 50» - подрезать нечего" fi return 1 } # --- 4. Prowlarr ------------------------------------------------------------ # Качаем с GitHub, как и TorrServer. Родной prowlarr.servarr.com части адресов отдаёт # 403: зависеть от того, чей IP спрашивает, установка не должна. Сборка та же самая, # версия совпадает. Запасной путь остался вторым. prowlarr_asset_name() { # $1 - ОС, $2 - uname -m local os arch case "$1" in linux) os=linux ;; macos) os=osx ;; *) return 1 ;; esac case "$2" in x86_64) arch=x64 ;; aarch64|arm64) arch=arm64 ;; *) return 1 ;; esac # У закреплённого релиза нет osx-core-x64 tar.gz: zip приложения не заменяем # правдоподобной ссылкой на несуществующий архив. [ "$os/$arch" != osx/x64 ] || return 1 printf '%s-core-%s.tar.gz' "$os" "$arch" } prowlarr_fallback_url() { # $1 - ОС, $2 - uname -m local os arch case "$1" in linux) os=linux ;; macos) os=osx ;; *) return 1 ;; esac case "$2" in x86_64) arch=x64 ;; aarch64|arm64) arch=arm64 ;; *) return 1 ;; esac [ "$os/$arch" != osx/x64 ] || return 1 printf 'https://prowlarr.servarr.com/v1/update/master/updatefile?os=%s&runtime=netcore&arch=%s' "$os" "$arch" } # Apple Silicon не исполняет arm64-код без подписи вовсе: сборка Prowlarr её не # несёт, и launchd получает OS_REASON_EXEC ещё до первой строки журнала (замер на # стенде: бинарь жив, `file` видит Mach-O arm64, а запуск кончается SIGKILL). # Ad-hoc подпись (`codesign -s -`) ничего не удостоверяет - она даёт ядру # обязательный для arm64 дескриптор кода; ставится локально, без сети и keychain. # Идемпотентно: уже подписанное (codesign -v) не трогаем. sign_macho_tree() { # $1 - каталог; ad-hoc подписывает каждый неподписанный Mach-O local f signed=0 while IFS= read -r f; do case "$(file -b "$f" 2>/dev/null)" in *Mach-O*) codesign -v "$f" 2>/dev/null && continue codesign --sign - --force "$f" >/dev/null \ || die "ad-hoc signing failed: $f" "ad-hoc подпись не встала: $f" signed=$(( signed + 1 )) ;; esac done </dev/null \ || die "Prowlarr refused certificateValidation=disabledForLocalAddresses" "Prowlarr не принял certificateValidation=disabledForLocalAddresses" info "certificate validation now relaxed for local addresses via the Prowlarr API" "проверка серта ослаблена для локальных адресов через API Prowlarr" fi loud "on macOS Prowlarr skips TLS certificate verification only for names resolving to local addresses (the shim names pinned to 127.0.0.1); indexers with public addresses are still verified" \ "на macOS Prowlarr снимает проверку серта только для имён, резолвящихся в локальные адреса (имена шима прибиты к 127.0.0.1); индексеры с публичными адресами проверяются по-прежнему" } install_prowlarr() { log "Prowlarr ($PL_URL, public indexers)" "Prowlarr ($PL_URL, публичные индексеры)" pick_python install -d -m 0755 "$PREFIX/prowlarr-data" install_prowlarr_binary # AniLibria отдаёт поиск релизов и их торренты двумя открытыми # REST-запросами. Местный адаптер склеивает их в обычную схему # Cardigann; слушает только localhost, ключей и состояния у него нет. if ! cmp -s "$REPO_DIR/scripts/anilibria-indexer.py" "$PREFIX/anilibria-indexer.py"; then stop_service anilibria-indexer "$PYTHON $PREFIX/anilibria-indexer.py" install -m 0755 "$REPO_DIR/scripts/anilibria-indexer.py" "$PREFIX/anilibria-indexer.py" fi run_service anilibria-indexer "Поиск AniLibria для Prowlarr" \ "$PYTHON $PREFIX/anilibria-indexer.py 9697" "" wait_http "http://localhost:9697/ping" 15 \ || info "⚠ AniLibria did not start - other indexers will continue working" "⚠ AniLibria не поднялась - остальные индексеры продолжат работать" if ! cmp -s "$REPO_DIR/scripts/jacred-indexer.py" "$PREFIX/jacred-indexer.py"; then stop_service jacred-indexer "$PYTHON $PREFIX/jacred-indexer.py" install -m 0755 "$REPO_DIR/scripts/jacred-indexer.py" "$PREFIX/jacred-indexer.py" fi run_service jacred-indexer "Открытый поиск русских раздач для Prowlarr" \ "$PYTHON $PREFIX/jacred-indexer.py 9698" "" wait_http "http://127.0.0.1:9698/ping" 15 \ || info "⚠ JacRed did not start - other indexers will continue working" "⚠ JacRed не поднялся - остальные индексеры продолжат работать" install -d -m 0755 "$PREFIX/prowlarr-data/Definitions" if ! cmp -s "$REPO_DIR/scripts/anilibria.yml" \ "$PREFIX/prowlarr-data/Definitions/anilibria.yml" \ || ! cmp -s "$REPO_DIR/scripts/jacred.yml" \ "$PREFIX/prowlarr-data/Definitions/jacred.yml"; then stop_service prowlarr "$PREFIX/prowlarr/Prowlarr" fi install -m 0644 "$REPO_DIR/scripts/anilibria.yml" \ "$PREFIX/prowlarr-data/Definitions/anilibria.yml" install -m 0644 "$REPO_DIR/scripts/jacred.yml" \ "$PREFIX/prowlarr-data/Definitions/jacred.yml" # 🔴 Решаем про определения ДО первого старта Prowlarr, и потому здесь, а не в фазе # источников: прибитое имя должно лежать в /etc/hosts раньше, чем служба пойдёт за # каталогом сама. Без этой строки КАЖДЫЙ запрос схемы ждёт таймаута .NET - 100 секунд. # Сама выгрузка с GitHub (восемь мегабайт) от нашей закачки не зависит и едет рядом. prepare_definitions # Конфиг пишем ДО первого старта: иначе Prowlarr сядет на 0.0.0.0 и включит # форму логина. Слушаем только localhost, аутентификация внешняя (её нет). if [ -f "$PREFIX/prowlarr-data/config.xml" ]; then skip "$PREFIX/prowlarr-data/config.xml" "$PREFIX/prowlarr-data/config.xml" else cat >"$PREFIX/prowlarr-data/config.xml" < $PL_HOST $PL_PORT False False External DisabledForLocalAddresses False master info XML fi run_service prowlarr "Prowlarr для torrcast" \ "$PREFIX/prowlarr/Prowlarr -nobrowser -data=$PREFIX/prowlarr-data" "$PL_IPV4" wait_http "$PL_URL/ping" 120 || die "Prowlarr did not start at $PL_URL" "Prowlarr не поднялся на $PL_URL" # Только для macOS и только после того, как служба отвечает: настройка едет через # API, а не через config.xml (причина - в комментарии функции). На Linux - no-op. prowlarr_cert_relax # Определения должны лежать на месте раньше, чем у Prowlarr спросят схему, - иначе # индексеров в ней просто не окажется. Служба поднимается ровно столько же времени, # сколько они качаются, поэтому ждём их здесь, а не раньше. if [ -n "$(job_pid_get defs)" ]; then job_wait defs || info "⚠ indexer definitions were not installed - built-in definitions remain" "⚠ определения индексеров не разложились - останутся встроенные" # 🔴 TC-232. Подрезаем ПОСЛЕ раскладки - и поднимаем службу заново, если правда # подрезали. Определение Prowlarr читает один раз и держит в памяти: замер на # стенде - правка на диске без рестарта выдачу не поменяла («batman» как отдавал # 132 строки, так и отдавал), а после рестарта стало 30, то есть ровно десять # картин. Рестарт стоит 1.9 с до готовности и случается только тогда, когда # определение и правда изменилось. if trim_yts "$PREFIX/prowlarr-data/Definitions"; then stop_service prowlarr "$PREFIX/prowlarr/Prowlarr" run_service prowlarr "Prowlarr для torrcast" \ "$PREFIX/prowlarr/Prowlarr -nobrowser -data=$PREFIX/prowlarr-data" "$PL_IPV4" wait_http "$PL_URL/ping" 120 || die "Prowlarr did not start at $PL_URL" "Prowlarr не поднялся на $PL_URL" fi fi } # apikey Prowlarr генерит себе сам при первом старте — просто вычитываем. prowlarr_apikey() { sed -n 's:.*\(.*\).*:\1:p' "$PREFIX/prowlarr-data/config.xml" | head -1 } # --- 5. Индексеры через API (ноль ручных шагов в вебе) ---------------------- #: Проверочный поиск в конце фазы: чем спрашиваем и сколько ждём ОДИН индексер. Ждать #: столько же, сколько ждёт сам Prowlarr (100 с у .NET), незачем: штатный поиск у живого #: индексера занимает 1-3 секунды, а мёртвый не ответит и за сто. Взятые 25 - восьмикратный #: запас к норме. Молчание за этот срок - не приговор индексеру, а честная строка в отчёте. #: ⚠️ Спрашивать поимённо дороже, чем одним общим поиском: общий Prowlarr отдаёт мгновенно, #: потому что молчащий индексер у него уже под штрафом и в опрос не попадает - то есть #: «проверка» на повторном заходе не проверяла ровно то, ради чего затевалась. PL_SEARCH_TIMEOUT="${TORRCAST_SEARCH_TIMEOUT:-25}" PL_SEARCH_PROBE="${TORRCAST_SEARCH_PROBE:-матрица}" #: Prowlarr сам щупает источник на POST. Одновременное добавление поэтому #: становится залпом к трекерам. Две секунды между началами оставляют #: на критическом пути ровно одну живую пробу. Переопределение нужно только #: для изолированной проверки установки. INDEXER_ADD_GAP="${TORRCAST_INDEXER_ADD_GAP:-2}" # Носитель роли каталога (:data:`CATALOG_ROLES`): роль, у которой не ответил ни один # носитель, оставляет каталог не беднее, а пустым. core_indexer() { # $1 - definitionName local role carrier carriers for role in "${CATALOG_ROLES[@]}"; do read -ra carriers <<<"${role#*|}" for carrier in "${carriers[@]}"; do [ "$carrier" = "$1" ] && return 0; done done return 1 } # Первый носитель роли: тот, с кого её спрашивают, и потому единственный, кого установка # добавляет на глазах у человека. Остальные носители - запас (:func:`late_indexer`). lead_indexer() { # $1 - definitionName local role carriers for role in "${CATALOG_ROLES[@]}"; do carriers="${role#*|}" [ "${carriers%% *}" = "$1" ] && return 0 done return 1 } late_indexer() { # $1 - definitionName; 0 = добавляем в фоне, а не на глазах у человека # Первый носитель роли не откладывается ни при каких условиях: фон не имеет права # спрятать то, без чего роль пуста. Все остальные откладываются, и правило это не # список, а роль: пока роль закрыта первым носителем, ждать чужой пробы человеку не # за что - ни запасному носителю, ни узкому источнику роли не поручено ничего. # Замолчавшую роль запасным закрывает гейт (:func:`catalog_gate`), и там сотня секунд # платится за правду о каталоге, а не за тишину перед «готово». lead_indexer "$1" && return 1 return 0 } # Причина отказа из тела ответа Prowlarr: он называет поле и причину (TC-692), и # прятать их за голым кодом нельзя. Не JSON или чужая форма - отдаём начало тела как есть. indexer_fail_reason() { # $1 - файл с телом ответа [ -s "$1" ] || return 0 jq -r 'if type=="array" then .[0].errorMessage // empty else .message // empty end | if length>0 then ": "+. else "" end' "$1" 2>/dev/null \ || printf ': %.200s' "$(tr -d '\n' <"$1")" } # Добавить отложенные индексеры. Тела запросов собраны заранее, так что здесь остаётся # только сходить в свой Prowlarr - никакой сети, кроме той, что он дёрнет сам. add_indexers() { # $1 - apikey; дальше пары «имятело» local key="$1" spec iname ibody idef have answer status shift for spec in "$@"; do IFS=$'\t' read -r iname ibody <<<"$spec" [ -n "$iname" ] || continue # Между запуском и этой минутой мог пройти второй заход установки и добавить его # сам - тогда просто уходим, а не плодим второй такой же индексер. have="$(curl -fsS "$PL_URL/api/v1/indexer?apikey=$key" 2>/dev/null \ | jq -r --arg n "$iname" 'any(.[]; .name==$n)' 2>/dev/null)" || have="" if [ "$have" = true ]; then info "$iname is already installed" "$iname уже на месте" continue fi answer="$(mktemp)" status="$(curl -sS -o "$answer" -w '%{http_code}' -X POST \ "$PL_URL/api/v1/indexer?apikey=$key" -H 'Content-Type: application/json' \ -d "$ibody" 2>/dev/null)" || status=000 if [[ "$status" = 2* ]]; then info "$iname added" "$iname добавлен" else # 🔴 TC-697/TC-966. «Каталог этим не урезан» человек обязан прочитать и здесь: # с 06-09-2026 узкие заводятся ТОЛЬКО в фоне, и другого места сказать ему это # не осталось. Носителю роли то же слово было бы ложью - ему тут молчим, за # него говорит громкая строка на глазах и переспрос (:func:`retry_add_indexers`). idef="$(jq -r '.definitionName // empty' <<<"$ibody" 2>/dev/null)" || idef="" if [ -n "$idef" ] && ! core_indexer "$idef"; then info "⚠ $iname was not added: Prowlarr returned HTTP $status$(indexer_fail_reason "$answer") - this does not make the catalog incomplete; the next ./install.sh will add it" \ "⚠ $iname не добавился: Prowlarr ответил HTTP $status$(indexer_fail_reason "$answer") - каталог этим не урезан, заведёт следующий ./install.sh" else info "⚠ $iname was not added: Prowlarr returned HTTP $status$(indexer_fail_reason "$answer")" "⚠ $iname не добавился: Prowlarr ответил HTTP $status$(indexer_fail_reason "$answer")" fi fi rm -f "$answer" done } # 🔴 TC-692. Отказ на добавление - это живая проба источника В ЭТУ МИНУТУ: Prowlarr не # заводит индексер, не спросившийся у трекера (forceSave=true этот экзамен не отменяет: # проверено живьём, тот же 400). А канал режет трекеры окнами (TC-260: в 19:00 имя # рвалось в 100% попыток, в 20:00 - в 0%), поэтому одиночный отказ в минуту установки - # погода, а не приговор. До этой карточки он был приговором: установка спрашивала один # раз и навсегда оставляла свежую машину без опорных источников, если окно в ту минуту # было закрыто. Переспрашиваем в догреве: окно откроется - индексер встанет сам. # 🔴 TC-697. TIMES - это ВСЕ пробы источника, считая ту, что была на глазах у человека: # отказавший уже спрошен, и догрев делает на одну меньше. INDEXER_RETRY_TIMES="${TORRCAST_INDEXER_RETRY_TIMES:-12}" INDEXER_RETRY_EVERY="${TORRCAST_INDEXER_RETRY_EVERY:-300}" retry_add_indexers() { # $1 - apikey; дальше пары «имятело», спрошенные на глазах local key="$1" spec iname list missing="" todo=() left=() shift todo=("$@") # 🔴 TC-697. Круг начинается с ПАУЗЫ: сюда едут те, кого установка спросила минуту # назад на глазах, и проба без паузы была бы вторым обращением к источнику в ту же # минуту - ничего не меняющим, но продлевающим ступень бана у трекера. for spec in "${todo[@]}"; do IFS=$'\t' read -r iname _ <<<"$spec" [ -n "$iname" ] || continue missing="$missing${missing:+, }$iname" done for _ in $(seq 2 "$INDEXER_RETRY_TIMES"); do sleep "$INDEXER_RETRY_EVERY" add_indexers "$key" "${todo[@]}" list="$(curl -fsS "$PL_URL/api/v1/indexer?apikey=$key" 2>/dev/null)" || list='[]' # На следующий круг едут только те, кого в Prowlarr так и нет: переспрашивать # вставшего - лишний круг к трекеру и строка «уже на месте» двенадцать раз. missing=""; left=() for spec in "${todo[@]}"; do IFS=$'\t' read -r iname _ <<<"$spec" [ -n "$iname" ] || continue jq -e --arg n "$iname" 'any(.[]; .name==$n)' <<<"$list" >/dev/null 2>&1 && continue missing="$missing${missing:+, }$iname" left+=("$spec") done [ -z "$missing" ] && return 0 todo=("${left[@]}") done info "⚠ still failed: $missing - the catalog is incomplete without them; rerun ./install.sh when the source responds" \ "⚠ так и не завелись: $missing - каталог без них неполный; повторный ./install.sh заведёт их, когда источник ответит" } # 🔴 TC-259/TC-272. Снять бан с индексеров, которых Prowlarr увёл в недоступные. # # Prowlarr отпускает по своим часам, а не по здоровью источника: замер на стенде - канал # пропадал на 12 с, а каталог оставался урезанным ещё 59.2 с ПОСЛЕ его возврата; отсрочка # растёт по отказам до часа, и бан переживает перезапуск службы (он в базе). Прошлая # гроза поэтому доживает до сегодняшней установки, а по списку индексеров её не видно: # забаненный так и стоит "enable": true. # # Ручки «снять бан» у Prowlarr 2.5.2.5491 нет: DELETE на /api/v1/indexerstatus отвечает # 405, PUT самого индексера с forceSave=true статус не трогает. Единственное, что его # гасит, - УСПЕШНАЯ проверка индексера: она ходит в источник по-настоящему. Отсюда и # честность: мёртвый источник этим не воскресить, неудачная проверка бан лишь продлит - # поэтому зовём её один раз за установку и только для тех, кто уже забанен. unban_indexers() { # $1 - apikey local key="$1" list ids id body names="" n=0 ids="$(curl -fsS "$PL_URL/api/v1/indexerstatus?apikey=$key" 2>/dev/null \ | jq -r '.[].indexerId' 2>/dev/null)" || return 0 [ -n "$ids" ] || return 0 list="$(curl -fsS "$PL_URL/api/v1/indexer?apikey=$key" 2>/dev/null)" || list='[]' for id in $ids; do body="$(curl -fsS "$PL_URL/api/v1/indexer/$id?apikey=$key" 2>/dev/null)" || continue if curl -fsS -X POST "$PL_URL/api/v1/indexer/test?apikey=$key" \ -H 'Content-Type: application/json' -d "$body" >/dev/null 2>&1; then n=$((n + 1)) names="$names${names:+, }$(jq -r --argjson i "$id" \ 'map(select(.id==$i))|.[0].name // "индексер \($i)"' <<<"$list")" fi done if [ "$n" -gt 0 ]; then info "cleared the ban on unavailable indexers: $names" "снят бан с недоступных индексеров: $names" else info "⚠ Prowlarr still marks indexers unavailable and the check did not restore them - sources are still silent" "⚠ Prowlarr держит индексеры недоступными, и проверка их не вернула - источники ещё молчат" fi } # Что источник отдаёт на пробный поиск: число раздач либо пусто, если он не ответил. # Спрашивают его двое - гейт каталога и догрев, - и вопрос у них обязан быть один и тот # же: разные вопросы дали бы разные ответы об одном и том же источнике. indexer_yield() { # $1 - apikey, $2 - номер индексера в Prowlarr curl -fsS -m "$PL_SEARCH_TIMEOUT" -G "$PL_URL/api/v1/search" \ --data-urlencode "apikey=$1" --data-urlencode "query=$PL_SEARCH_PROBE" \ --data-urlencode "type=search" --data-urlencode "limit=100" \ --data-urlencode "indexerIds=$2" 2>/dev/null | jq 'length' 2>/dev/null } # Проверочные поиски по индексерам, кроме опорных: «индексер заведён» и «поиск что-то # находит» - разные утверждения, но ждать второго человеку незачем. Носителей ролей # спрашивает гейт на глазах (:func:`catalog_gate`: роль без ответившего носителя # оставляет поиск почти пустым - у 97 запросов из 99), остальных - здесь. # 🔴 TC-292. Спрашиваем СТРОГО по одному, как и лечение бана (`IndexerRoster._heal` в # torrcast/adapters/prowlarr/indexer_roster.py): залп одновременных поисков - это ровно # тот образ действий, за который # трекер раздаёт баны. Живая проба: `POST /indexer/testall` САМ забанил Nyaa, а через # шим Nyaa держит 2-4 одновременных запроса, дальше 504 и health-бан Prowlarr на часы. # Установке это ничего не стоит: функция живёт в догреве (`late_run`), и человек её не # ждёт - а вот забаненный индексер на выходе установки ждать приходится часами. check_indexers() { # $1 - apikey; дальше тройки «номеримя» local key="$1" spec id iname n shift for spec in "$@"; do IFS=$'\t' read -r id iname <<<"$spec" [ -n "$id" ] || continue n="$(indexer_yield "$key" "$id")" || n="" if [ -z "$n" ]; then info "⚠ $iname: no response within $PL_SEARCH_TIMEOUT s" "⚠ $iname: не ответил за $PL_SEARCH_TIMEOUT с" else info "$iname: $n results in test search '$PL_SEARCH_PROBE'" "$iname: $n раздач в проверочном поиске «${PL_SEARCH_PROBE}»" fi done } #: 🔴 TC-692. Урезан ли каталог на выходе установки: роли, у которых не ответил ни один #: носитель, и что стало с каждым из них. Пусто - каталог полный. Читает :func:`main`: #: пустой каталог под видом успешной установки - это неправда, и она обязана быть видна #: и словами, и кодом. CATALOG_CUT_EN="" CATALOG_CUT_RU="" #: Код возврата установки, которая поставилась, но каталога не получила. Тот же смысл и то #: же число, что у инфра-ошибки `cast` (torrcast/domain/exit_codes.py, EXIT_INFRA): всё #: своё на месте, а среда не дала работать. Ноль тут врал бы автоматике так же, как #: «готово» врало бы человеку. EXIT_CATALOG_CUT=2 #: 🔴 TC-705. Запасные носители ролей: готовое тело запроса по definitionName. Их #: добавляет догрев, уже после «готово», - но если роль осталась без ответа, гейт заводит #: запасного сам: вердикт о каталоге нельзя выносить о том, чего не спрашивали. CATALOG_STANDBY_KEYS=() CATALOG_STANDBY_VALUES=() #: Кого гейт завёл сам (по имени в Prowlarr): догреву его добавлять больше нечего. CATALOG_PROMOTED=() #: Запасной, который на добавлении отказал, а роль его ждёт: едет в тот же переспрос, что #: и отказавшие на глазах, - первый повтор там после полной паузы. CATALOG_RETRY=() #: Номер, который Prowlarr дал заведённому запасному: :func:`promote_standby` возвращает #: его переменной, а не печатью - печать у него занята строкой для человека. STANDBY_ID="" catalog_standby_set() { CATALOG_STANDBY_KEYS+=("$1") CATALOG_STANDBY_VALUES+=("$2") } catalog_standby_get() { local wanted="$1" i for (( i = 0; i < ${#CATALOG_STANDBY_KEYS[@]}; i++ )); do [ "${CATALOG_STANDBY_KEYS[$i]}" = "$wanted" ] && { printf '%s' "${CATALOG_STANDBY_VALUES[$i]}"; return; } done } catalog_promoted() { local wanted="$1" name # Форма с `+`: bash 3.2 (macOS) под set -u считает пустой массив unbound. for name in ${CATALOG_PROMOTED[@]+"${CATALOG_PROMOTED[@]}"}; do [ "$name" = "$wanted" ] && return 0; done return 1 } # Завести запасного носителя роли прямо сейчас, на глазах у человека. Зовут отсюда только # тогда, когда роль осталась без ответа: тогда его добавление - не задержка перед # «готово», а единственный способ узнать, урезан каталог или цел. promote_standby() { # $1 - apikey, $2 - definitionName; ответ - в :data:`STANDBY_ID` local key="$1" def="$2" spec iname ibody answer status STANDBY_ID="" spec="$(catalog_standby_get "$def")" [ -n "$spec" ] || return 0 IFS=$'\t' read -r iname ibody <<<"$spec" answer="$(mktemp)" status="$(curl -sS -o "$answer" -w '%{http_code}' -X POST \ "$PL_URL/api/v1/indexer?apikey=$key" -H 'Content-Type: application/json' \ -d "$ibody" 2>/dev/null)" || status=000 # Завёлся он или отказал - догреву его больше не поручаем: второе обращение к тому же # источнику в ту же минуту ничего не меняет, а ступень бана у трекера продлевает. CATALOG_PROMOTED+=("$iname") if [[ "$status" = 2* ]]; then STANDBY_ID="$(jq -r '.id // empty' "$answer" 2>/dev/null)" info "added $iname" "добавлен $iname" else loud "$iname was not added: Prowlarr returned HTTP $status$(indexer_fail_reason "$answer")" "$iname не добавился: Prowlarr ответил HTTP $status$(indexer_fail_reason "$answer")" CATALOG_RETRY+=("$spec") fi rm -f "$answer" } # Гейт каталога: роль обязана быть КЕМ-ТО закрыта, а «источник числится» и «источник # отвечает» - разные утверждения. # 🔴 Замер живьём: rutor заводился, стоял в списке включённым - и не отдавал ничего, ни # одной раздачи за 25 с. Проверка «добавился ли» такую установку объявляла успешной, а # каталог у неё был пуст. Поэтому здесь спрашивается сам поиск, а не список. # 🔴 TC-705. Спрашивается он по ролям (:data:`CATALOG_ROLES`), а не по именам, и роль # закрывает первый же ответивший носитель. Замер живьём: rutor не завёлся вовсе - # установка называла каталог урезанным, хотя русские раздачи в нём несёт второй источник, # которого прежняя проверка не спрашивала вообще. Судить надо по тому, что каталог правда # несёт: имя в списке ни о чём таком не говорит. # Лишнего обращения к трекеру это не стоит: носителей ролей раньше щупали тем же поиском в # догреве (:func:`check_indexers`), просто уже после «готово» - там их молчание никого не # останавливало. Теперь оно спрашивается на глазах, а из догрева они убраны. catalog_gate() { # $1 - apikey, $2 - список индексеров, $3 - схема Prowlarr; печатает вердикт local key="$1" list="$2" schema="$3" local role names name_en name_ru carriers def id iname n why_en why_ru covered cut_en="" cut_ru="" for role in "${CATALOG_ROLES[@]}"; do names="${role%%|*}" name_en="${names%%^*}" name_ru="${names#*^}" read -ra carriers <<<"${role#*|}" why_en=""; why_ru=""; covered="" for def in "${carriers[@]}"; do [ -z "$covered" ] || break IFS='|' read -r id iname < <(jq -r --arg d "$def" \ 'first(.[]|select(.definitionName==$d and (.enable != false))|"\(.id)|\(.name)") // "|"' <<<"$list") # Имя для человека берём из схемы: у ненавёрстанного индексера в списке его нет, а # печатать вместо имени внутренний идентификатор определения - это загадка, не строка. [ -n "$iname" ] || iname="$(jq -r --arg d "$def" \ 'first(.[]|select(.definitionName==$d)|.name) // $d' <<<"$schema")" # Роль ещё никем не закрыта, а этого носителя заводит догрев: заводим здесь. # Ровно за это установка и платит секундами - и платит там, где иначе назвала # бы урезанным полный каталог либо смолчала бы о пустом. if [ -z "$id" ] && [ -n "$(catalog_standby_get "$def")" ]; then info "role '$name_en' is unanswered - adding fallback source $iname (Prowlarr checks it; this may take up to two minutes)" \ "роль «${name_ru}» пока без ответа - завожу запасной источник $iname (Prowlarr щупает его сам, это до двух минут)" promote_standby "$key" "$def" id="$STANDBY_ID" fi if [ -z "$id" ]; then why_en="$why_en${why_en:+, }$iname (not added)" why_ru="$why_ru${why_ru:+, }$iname (не завёлся)" continue fi n="$(indexer_yield "$key" "$id")" || n="" if [ -z "$n" ]; then why_en="$why_en${why_en:+, }$iname (no response within $PL_SEARCH_TIMEOUT s)" why_ru="$why_ru${why_ru:+, }$iname (не ответил за $PL_SEARCH_TIMEOUT с)" elif [ "$n" -eq 0 ]; then why_en="$why_en${why_en:+, }$iname (added but returned no results)" why_ru="$why_ru${why_ru:+, }$iname (заведён, но не отдал ничего)" else info "$iname responds: $n results in test search '$PL_SEARCH_PROBE'" "$iname отвечает: $n раздач в проверочном поиске «${PL_SEARCH_PROBE}»" covered=1 fi done # Роль закрыта первым же ответившим: остальных её носителей не спрашиваем вовсе - # ответ они не изменят, а обращение к трекеру стоит суток его ступени бана. if [ -z "$covered" ]; then cut_en="$cut_en${cut_en:+; }$name_en - $why_en" cut_ru="$cut_ru${cut_ru:+; }$name_ru - $why_ru" fi done [ -z "$cut_en" ] && return 0 CATALOG_CUT_EN="$cut_en" CATALOG_CUT_RU="$cut_ru" loud "catalog has no sources for role: $cut_en" "каталог остался без источников роли: $cut_ru" info "the remaining indexers are narrow and do not cover this gap: searches will find little or nothing until a source responds" \ "остальные индексеры узкие и этой дыры не закрывают: поиск будет находить мало или ничего, пока источник не ответит" } install_indexers() { log "Prowlarr indexers" "индексеры Prowlarr" local key schema existing key="$(prowlarr_apikey)" [ -n "$key" ] || die "could not read apikey from Prowlarr config.xml" "не вычитал apikey из config.xml Prowlarr" # Гейт версии: обвязка рассчитана на конкретный формат ответа Prowlarr. Если # поставилась версия, которая отвечает иначе (например, после отката пина на # latest), это должно быть видно здесь и словами, а не молчаливым выходом. schema="$(curl -fsS "$PL_URL/api/v1/indexer/schema?apikey=$key")" \ || die "Prowlarr did not return the indexer schema ($PL_URL/api/v1/indexer/schema) - this version has an incompatible API" "Prowlarr не отдал схему индексеров ($PL_URL/api/v1/indexer/schema) - API этой версии не тот, на который рассчитана установка" existing="$(curl -fsS "$PL_URL/api/v1/indexer?apikey=$key")" \ || die "Prowlarr did not return the indexer list ($PL_URL/api/v1/indexer)" "Prowlarr не отдал список индексеров ($PL_URL/api/v1/indexer)" jq -e 'type == "array" and length > 0 and all(.[]; has("definitionName"))' <<<"$schema" >/dev/null 2>&1 \ || die "Prowlarr indexer schema has an unexpected shape - this version has an incompatible API" "схема индексеров Prowlarr не в ожидаемом виде - API этой версии не тот, на который рассчитана установка" local spec def url extra over body name local late=() retry=() answer status first=1 for spec in "${INDEXERS[@]}"; do IFS='|' read -r def url extra <<<"$spec" name="$(jq -r --arg d "$def" '.[]|select(.definitionName==$d)|.name' <<<"$schema")" if [ -z "$name" ] || [ "$name" = null ]; then info "⚠ $def is absent from this Prowlarr version's schema - skipping" "⚠ $def нет в схеме этой версии Prowlarr - пропускаю" continue fi if jq -e --arg n "$name" 'any(.[]; .name==$n)' <<<"$existing" >/dev/null; then skip "indexer $name" "индексер $name" continue fi # Поля определения, которые перебиваем: базовый URL плюс то, что задано третьим # полем строки (`поле=значение`, через пробел). Остальные берутся из схемы как есть. over="$(jq -cn --arg u "$url" --arg e "${extra:-}" ' ($e|split(" ")|map(select(length>0)|split("=")|{key:.[0],value:(.[1:]|join("="))}) |from_entries) + {baseUrl:$u}')" body="$(jq -c --arg d "$def" --argjson o "$over" ' .[]|select(.definitionName==$d) |{name,implementation,configContract,definitionName,priority,protocol, enable:true, appProfileId:1, tags:[], added:"0001-01-01T00:00:00Z", fields:[.fields[]|{name, value:(if $o[.name] != null then $o[.name] else .value end)}]} ' <<<"$schema")" # Тот, кому не место на критическом пути, уезжает в фон целиком (:func:`late_indexer`): # его тело собрано, а добавит его подоболочка уже после «готово». if late_indexer "$def"; then late+=("$(printf '%s\t%s' "$name" "$body")") # Запасной носитель роли кладёт своё тело под руку гейту: если роль замолчит, # гейт заведёт его сам, не дожидаясь догрева (:data:`CATALOG_STANDBY`). if core_indexer "$def"; then catalog_standby_set "$def" "$(printf '%s\t%s' "$name" "$body")" fi continue fi # Каждый POST запускает живую пробу Prowlarr. Начинаем следующую только # после конца предыдущей. Если бан уже пойман, тело отказа называет # это честнее, чем догадка о сети; установка в обоих случаях идёт дальше. [ "$first" = 1 ] || sleep "$INDEXER_ADD_GAP" first=0 answer="$(mktemp)" status="$(curl -sS -o "$answer" -w '%{http_code}' -X POST \ "$PL_URL/api/v1/indexer?apikey=$key" -H 'Content-Type: application/json' \ -d "$body" 2>/dev/null)" || status=000 if [[ "$status" = 2* ]]; then info "added $name" "добавлен $name" elif core_indexer "$def"; then # 🔴 TC-692. Это отказ КАТАЛОГА, а не «не блокер»: без опорного источника # установка остаётся с почти пустым поиском, и человек обязан это видеть. # Установку не роняем, но и успехом не называем: громкая строка с причиной # из тела ответа, а дальше догрев переспросит отказавшие ещё не раз # (:func:`retry_add_indexers`) - отказ здесь чаще всего погода канала. retry+=("$(printf '%s\t%s' "$name" "$body")") loud "$name was not added: Prowlarr returned HTTP $status$(indexer_fail_reason "$answer") - the catalog is incomplete without it" "$name не добавился: Prowlarr ответил HTTP $status$(indexer_fail_reason "$answer") - без него каталог неполный" info "it will be retried in the background; see its state in cast doctor" "переспросим его в фоне ещё не раз; состояние видно в cast doctor" else # 🔴 TC-697. Узкий отказавший не переспрашивается: каталог его отсутствия не # замечает, а каждое обращение к молчащему источнику продлевает его сутки бана. info "⚠ $name was not added: Prowlarr returned HTTP $status$(indexer_fail_reason "$answer") - this does not make the catalog incomplete; the next ./install.sh will add it" \ "⚠ $name не добавился: Prowlarr ответил HTTP $status$(indexer_fail_reason "$answer") - каталог этим не урезан, заведёт следующий ./install.sh" fi rm -f "$answer" done # Живая проверка: «индексер заведён» и «поиск что-то находит» - разные утверждения. # Первое бывает правдой при неправде второго - например когда сеть режет индексер. # 🔴 На глазах у человека проверяются ОПОРНЫЕ (:func:`catalog_gate`): их молчание # оставляет каталог пустым, и оно обязано быть видно в самой установке. Узкие друг # друга подстраховывают, и ждать их молчания (до $PL_SEARCH_TIMEOUT с каждому) # человеку незачем - их проверка уезжает в догрев, к оценкам IMDb. # Бан прошлой грозы переживает и перезапуск службы, и эту установку, а на проверке # ниже выглядел бы как «индексер заведён, но не отдал ничего» - то есть врал бы про # источник. Снимаем до проверки, чтобы проверялся источник, а не отсрочка Prowlarr. unban_indexers "$key" local list rest=() id iname list="$(curl -fsS "$PL_URL/api/v1/indexer?apikey=$key")" || list='[]' info "indexers now: $(jq 'length' <<<"$list")" "индексеров сейчас: $(jq 'length' <<<"$list")" while IFS='|' read -r id def iname; do [ -n "$id" ] || continue # Опорные щупаются здесь же, на глазах (:func:`catalog_gate`), поэтому в догрев # они не попадают: дважды один поиск - это дважды обращение к трекеру. core_indexer "$def" || rest+=("$(printf '%s\t%s' "$id" "$iname")") done < <(jq -r '.[]|"\(.id)|\(.definitionName)|\(.name)"' <<<"$list") # 🔴 TC-692/TC-705. Гейт каталога вместо прежнего гейта одного ключевого индексера: # роль, у которой не ответил никто, оставляет поиск почти пустым. Установку не роняем # (деградируем, а не умираем), но и успехом такую не называем: `main` скажет об этом # последней строкой и вернёт ненулевой код. catalog_gate "$key" "$list" "$schema" # Догрев: сперва то, ради чего он заведён (индексер на сто секунд), следом проверка # остальных. Порядок не случаен - проверять имеет смысл уже полный список. # 🔴 TC-697. Узкий индексер спрашивается ОДИН раз, и переспрос - привилегия опорных. # Переспрос стоит обращений к источнику, а ступень бана у трекера - сутки, и каждое # обращение её продлевает. За узкий платить их нечем: yts даёт +2.1% раздач и НОЛЬ # запросов, где он единственный источник играбельного HD, - его отсутствия каталог не # замечает, а следующий заход установки заведёт его сам. Без опорных пул пуст у 97 # запросов из 99 (TC-692), поэтому переспрашиваются только они, и отдельной строкой. local names="" ready=() for spec in ${late[@]+"${late[@]}"}; do iname="${spec%%$'\t'*}" # Кого гейт завёл сам, догреву поручать нечего (:data:`CATALOG_PROMOTED`): второе # добавление того же источника - лишнее обращение и лишняя строка про ожидание. catalog_promoted "$iname" && continue ready+=("$spec") names="$names${names:+, }$iname" done if [ "${#ready[@]}" -gt 0 ]; then late_run "indexer $names (may take up to two minutes to add)" "индексер $names (добавляется до двух минут)" add_indexers "$key" "${ready[@]}" fi # Запасной, отказавший на добавлении у гейта, ждёт свою роль так же, как отказавший на # глазах: переспрос - привилегия носителей роли, и начинается он с полной паузы. if [ "${#CATALOG_RETRY[@]}" -gt 0 ]; then retry+=("${CATALOG_RETRY[@]}") fi if [ "${#retry[@]}" -gt 0 ]; then local asked="" more=$(( INDEXER_RETRY_TIMES - 1 )) local span=$(( more * INDEXER_RETRY_EVERY / 60 )) for spec in "${retry[@]}"; do asked="$asked${asked:+, }${spec%%$'\t'*}"; done late_run "failed core indexers $asked (up to $more more attempts every $INDEXER_RETRY_EVERY s, up to $span min)" \ "отказавшие опорные $asked (спросим ещё до $more раз, раз в $INDEXER_RETRY_EVERY с - это до $span мин)" \ retry_add_indexers "$key" "${retry[@]}" fi if [ "${#rest[@]}" -gt 0 ]; then late_run "test search through the remaining indexers" "проверочный поиск по остальным индексерам" check_indexers "$key" "${rest[@]}" fi } # --- 6. Конфиг, ключи, состояние -------------------------------------------- #: 🔴 TC-549. Ключи, которые повторная установка переносит из прежнего конфига. Всё #: остальное она собирает заново из умолчаний кода - решение владельца: «каждый #: перепрогон установки сносит всё лишнее и ставит всё нужное». #: Список обязан совпадать с :data:`torrcast.domain.owned_config_keys.OWNED_BY_HUMAN` #: (минус язык, он ниже) плюс поля :class:`tgbot.config.Config`; сторож расхождения - #: `tests/domain/test_owned_config_keys.py`. #: Языка тут нет НАРОЧНО, хотя владеет им тоже человек: его переносит не эта строка, а #: `$LANGUAGE`, который прочитан из живого конфига ещё до поднятия прав (см. самый верх #: файла). Перенеси его ещё и здесь - и `-ru` при повторной установке молча отменялся бы #: прежним значением. CONFIG_KEPT='tv receiver receiver_profile torrserver_url prowlarr_url prowlarr_apikey token chat_id proxy' setup_config() { log "configuration and keys" "конфиг и ключи" install -d -m 0755 "$CONFIG_DIR" "$STATE_DIR" local key; key="$(prowlarr_apikey)" local cfg="$CONFIG_DIR/config.json" prev='' # 🔴 TC-549. Конфиг продукта принадлежит КОДУ, и установка собирает его заново # целиком. Прежде полярность была обратной: живой конфиг сохранялся, а из него # вычищался поимённый чёрный список настроенных чисел - и всякое поднятое умолчание # приходилось дописывать в тот список руками. Забытое молча жило на старом числе: # `warm_budget_gb` в него не попал, и у поставивших продукт раньше бюджет прогрева # остался 20 ГБ против 30 в коде (замер 14-08: худший вечер требует 28). Прогрев на # живом прогоне 12-08 в это уже упирался и вставал посреди фильма. # Из прежнего файла переносится только то, чего из кода не вывести (CONFIG_KEPT): # адрес приёмника и его род, адреса источников и ключ Prowlarr, три ключа бота. # Состояние показа (выбор озвучки по тайтлу и закладка) тут не при чём вовсе: оно # живёт не в конфиге, а в $STATE_DIR/state.json, который установка только создаёт. if [ -f "$cfg" ]; then prev="$(mktemp "$CONFIG_DIR/.config.json.XXXX")" cat "$cfg" >"$prev" skip "$cfg (rebuilt from code; receiver, sources and bot carry over)" \ "$cfg (пересобираю из кода, переношу приёмник, источники и бота)" fi # Через временный файл, а не поверх живого: mktemp даёт 0600 сразу, и оборванная # установка не оставляет человека с полуписаным конфигом. local fresh; fresh="$(mktemp "$CONFIG_DIR/.config.json.XXXX")" cat >"$fresh" <"$tmp" mv "$tmp" "$fresh" rm -f "$prev" fi mv "$fresh" "$cfg" chmod 0600 "$cfg" info "Prowlarr apikey stored in $cfg" "apikey Prowlarr перенесён в $cfg" } # --- 6.4 Служба Telegram-бота ------------------------------------------------ # 🔴 Бот - не команда, а служба: он живёт между командами и обязан пережить # перезагрузку. До 31-08-2026 его не заводил никто. `cast -tg` сохранял настройку и # слал в чат проверочную надпись (её шлёт сам мастер), человек считал бота живым - а # опрашивать Telegram было некому, и всякая команда из чата уходила в пустоту. # Юнит кладётся на диск всегда, включается - только при настроенном Telegram: пустой # конфиг гонял бы бота по кругу падений. Обычно включает его сам мастер # (tgbot/enable_bot_unit.py), здесь - ради переустановки: машина, # настроенная раньше, обязана вернуться с живым ботом и уже с новым кодом. setup_bot_unit() { if [ -n "${TORRCAST_NO_SYSTEMD:-}" ]; then info "systemd is off - the torrcast-bot.service unit is not written (sandbox)" \ "systemd выключен - юнит torrcast-bot.service не кладу (песочница)" return 0 fi # ⚠️ Спрашиваем ДО правки юнита, как и run_service: `enable --now` уже поднятую # службу не перезапустит, и бот остался бы жить со старым кодом после обновления. local was_up=0 if [ "${OS_FAMILY:-linux}" = macos ]; then launchctl print system/org.torrcast.torrcast-bot >/dev/null 2>&1 && was_up=1 else systemctl is-active --quiet torrcast-bot.service && was_up=1 fi write_unit torrcast-bot "Telegram-бот torrcast" "$PREFIX/venv/bin/torrcast-bot" || true local bot_ready='' bot_ready="$(jq -r '((.token // "") != "") and ((.chat_id // "") != "")' "$CONFIG_DIR/config.json" 2>/dev/null || true)" if [ "$bot_ready" != true ]; then info "Telegram bot is not set up yet - set it up with: cast -tg" \ "Telegram-бот ещё не настроен - настрой командой: cast -tg" return 0 fi if [ "${OS_FAMILY:-linux}" = macos ]; then [ "$was_up" = 0 ] || launchd_bootout org.torrcast.torrcast-bot launchctl bootstrap system /Library/LaunchDaemons/org.torrcast.torrcast-bot.plist else systemctl enable --now torrcast-bot.service [ "$was_up" = 1 ] && systemctl restart torrcast-bot.service fi info "Telegram bot service is up" "служба Telegram-бота поднята" return 0 } # --- 6.5 Служба моста Home Assistant ----------------------------------------- # Мост - тоже служба, а не команда: карточка плеера опрашивает его постоянно, и после # перезагрузки он обязан подняться сам. Мастера у него нет и не нужно: настраивать # нечего, порт один, а телевизор он читает из общего конфига. Поэтому в отличие от бота # юнит включается сразу - пустого конфига, от которого мост падал бы по кругу, тут не # бывает: без телевизора он честно отвечает карточке, что показывать некуда. setup_ha_unit() { if [ -n "${TORRCAST_NO_SYSTEMD:-}" ]; then info "systemd is off - the torrcast-ha.service unit is not written (sandbox)" \ "systemd выключен - юнит torrcast-ha.service не кладу (песочница)" return 0 fi # ⚠️ Спрашиваем ДО правки юнита, как и у бота: `enable --now` уже поднятую службу не # перезапустит, и мост остался бы жить со старым кодом после обновления. local was_up=0 if [ "${OS_FAMILY:-linux}" = macos ]; then launchctl print system/org.torrcast.torrcast-ha >/dev/null 2>&1 && was_up=1 else systemctl is-active --quiet torrcast-ha.service && was_up=1 fi write_unit torrcast-ha "мост torrcast для Home Assistant" "$PREFIX/venv/bin/torrcast-ha" || true # Юнита на диске нет - поднимать нечего. Так выглядит установка без прав на # /etc/systemd: у бота эта же ветка не срабатывает лишь потому, что он уходит раньше # по ненастроенному токену. Мосту настраивать нечего, и без этой развилки песочница # ловила бы отказ `enable` на несуществующей службе. if [ "${OS_FAMILY:-linux}" = macos ]; then [ -f /Library/LaunchDaemons/org.torrcast.torrcast-ha.plist ] || return 0 [ "$was_up" = 0 ] || launchd_bootout org.torrcast.torrcast-ha launchctl bootstrap system /Library/LaunchDaemons/org.torrcast.torrcast-ha.plist else [ -f /etc/systemd/system/torrcast-ha.service ] || return 0 systemctl enable --now torrcast-ha.service [ "$was_up" = 1 ] && systemctl restart torrcast-ha.service fi info "Home Assistant bridge service is up on port 8479" \ "служба моста Home Assistant поднята на порту 8479" return 0 } # --- 6.6 Приёмник ------------------------------------------------------------ # Последний кусок настройки, и ручным он быть не должен: «установка кончилась, теперь # позови cast --tv» - это шаг, стоящий прямо на входе в продукт. Поэтому приёмник # подхватывается здесь же, штатным сценарием `cast --tv`: единственный найденный в # сети - он и есть телевизор, и берётся без вопроса. Несколько сценарий перечислит и откажется # выбирать без терминала: стандартный вход закрыт нарочно. Никого не нашли (телевизор выключен или в # другой сети) - установке это не мешает: не хватает ровно одной строки конфига, и её # допишет повторный ./install.sh при включённом телевизоре. #: Итог фазы приёмника - в рисовалку, каналом прогресса. Едут ВИД, имя и адрес, а #: не готовая строка: язык знает рисовалка, и английская установка не увидит #: кириллицы даже случайно. Строку из этого собирает :func:`ui_recv_fill`. receiver_said() { # $1 - адрес, $2 - имя (может быть пустым) # mock - headless-стенд, каста наружу у него нет вовсе, и «телевизор настроен» # про него было бы враньём: вид отдельный, надпись у него своя. if [ "$1" = mock ]; then ui_say R mock; return 0; fi ui_say R "set$UI_TAB$2$UI_TAB$1" return 0 } #: Имя приёмника из строки, которую печатает `cast --tv`: «ТВ: <имя> - <адрес>». #: Имени у устройства может не быть вовсе - тогда строка это «ТВ: <адрес>», и имя #: пустое. Второй раз сеть не сканируем: разбираем то, что уже напечатано. receiver_name() { # $1 - вывод cast --tv, $2 - адрес; имя на stdout local line name while IFS= read -r line; do case $line in 'ТВ: '*" - $2"|'TV: '*" - $2") name=${line#*': '} printf '%s' "${name% - "$2"}" return 0 ;; esac done <&1)" || rc=$? if [ -n "$out" ]; then printf '%s\n' "$out"; fi if [ "$rc" = 0 ]; then tv="$(jq -r '.tv // empty' "$cfg")" if [ -n "$had_tv" ] && [ "$tv" != "$had_tv" ]; then # Нашёлся один, но другой, чем был в конфиге: заменить выбор человека # молча значило бы переписать его без спроса. Возвращаем прежний адрес. local tmp; tmp="$(mktemp "$CONFIG_DIR/.config.json.XXXX")" jq --arg t "$had_tv" '.tv=$t' "$cfg" >"$tmp" mv "$tmp" "$cfg" info "configured receiver $had_tv did not answer this search; $tv did - run cast --tv $tv to switch, or cast --tv to search again" \ "настроенный приёмник $had_tv не отозвался при поиске; ответил $tv - выполни cast --tv $tv, чтобы переключиться, или cast --tv для нового поиска" return 0 fi receiver_said "$tv" "$(receiver_name "$out" "$tv")" return 0 fi receiver_choice "$out" if [ -n "$had_tv" ]; then info "configured receiver $had_tv did not answer this search; pick from the list above, or run cast --tv $had_tv if it comes back" \ "настроенный приёмник $had_tv не отозвался при поиске; выбери из списка выше, либо выполни cast --tv $had_tv, если он вернётся" else info "no receiver selected; run cast --tv with its address, or cast --tv to choose by number; if the list is empty, turn the TV on first" \ "приёмник не выбран; выполни cast --tv с адресом нужного или cast --tv для выбора номером; если список пуст, сначала включи телевизор" fi } # --- 7. Юниты и https -------------------------------------------------------- # Локаль службам задаём явно: systemd не наследует ни /etc/default/locale, ни # /etc/environment, поэтому без Environment= процесс живёт в POSIX-локали - кириллица # в его журнале и в именах файлов приезжает кракозябрами. Значение то же, которое # выбрала фаза `locale`. write_unit() { # $1 имя, $2 описание, $3 команда, $4 - строки [Service], $5 - не писать skip if [ "${OS_FAMILY:-linux}" = macos ]; then local label="org.torrcast.$1" path="/Library/LaunchDaemons/org.torrcast.$1.plist" local env_xml="" line knob name value while IFS= read -r line; do case "$line" in Environment=*) knob="${line#Environment=}" ;; *) continue ;; esac case "$knob" in \"*\") knob="${knob#\"}"; knob="${knob%\"}" ;; esac name="${knob%%=*}"; value="${knob#*=}" value="${value//&/\&}"; value="${value///\>}" env_xml="$env_xml $name$value"$'\n' done < Label$label ProgramArguments/bin/sh-cexec $command EnvironmentVariables LANG$LOCALE PATH$path_xml $env_xml RunAtLoad KeepAliveSuccessfulExit ThrottleInterval5 StandardOutPath$PREFIX/$1.log StandardErrorPath$PREFIX/$1.log PLIST )" if [ -f "$path" ] && [ "$(cat "$path")" = "$body" ]; then [ -n "${5:-}" ] || skip "$1 launchd job" "задание launchd $1" return 1 fi printf '%s\n' "$body" >"$path" chmod 0644 "$path" return 0 fi local path="/etc/systemd/system/$1.service" local body body="$(cat <"$path" systemctl daemon-reload } # Своего демона раздачи нет: сервер живёт внутри процесса `cast` ровно на время # показа — отдельного caddy/nginx с их конфигами не заводим. # Здесь только то, что должно существовать до первого запуска: каталог сегментов, а при # выключенной по умолчанию опции `transport: https` — ещё и серт. setup_hls() { log "HLS delivery ($HLS_TRANSPORT, port $HLS_PORT, segments in $HLS_DIR)" "раздача HLS ($HLS_TRANSPORT, порт $HLS_PORT, сегменты в $HLS_DIR)" install -d -m 0755 "$HLS_DIR" if [ "${OS_FAMILY:-linux}" = macos ] && [ -z "${TORRCAST_HLS_DIR:-}" ]; then loud "macOS has no default tmpfs: each show writes its 0.5-1.2 GiB HLS segment window to $HLS_DIR on disk" \ "у macOS нет штатного tmpfs: каждый показ пишет окно HLS-сегментов 0.5-1.2 ГиБ на диск в $HLS_DIR" fi if [ "$HLS_TRANSPORT" != "https" ]; then info "delivery address follows the route to the TV - no certificate, hostname, or DNS" "адрес раздачи собирается по маршруту до ТВ - ни серта, ни имени, ни DNS" return fi install -d -m 0700 "$TLS_DIR" if [ -s "$TLS_DIR/torrcast.crt" ] && [ -s "$TLS_DIR/torrcast.key" ]; then skip "certificate $TLS_DIR/torrcast.crt (until $(openssl x509 -noout -enddate -in "$TLS_DIR/torrcast.crt" | cut -d= -f2))" \ "серт $TLS_DIR/torrcast.crt (до $(openssl x509 -noout -enddate -in "$TLS_DIR/torrcast.crt" | cut -d= -f2))" return fi # Self-signed = рабочий дефолт для mock-приёмника. Chromecast его молча не примет: # сюда кладутся файлы настоящего серта (или правится путь в config.json) — и всё. openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ -keyout "$TLS_DIR/torrcast.key" -out "$TLS_DIR/torrcast.crt" \ -subj "/CN=$HLS_HOST" -addext "basicConstraints=critical,CA:TRUE" \ -addext "subjectAltName=DNS:$HLS_HOST,DNS:localhost,IP:127.0.0.1" 2>/dev/null chmod 600 "$TLS_DIR/torrcast.key" info "issued a self-signed certificate for $HLS_HOST - sufficient for mock acceptance" "выпущен self-signed на $HLS_HOST - mock-приёмке этого достаточно" info "⚠ a real TV needs an LE certificate: Chromecast silently rejects self-signed certificates" "⚠ живому ТВ нужен серт LE: Chromecast self-signed молча не играет" } setup_facts() { log "menu data: IMDb ratings ($IMDB_RATINGS_PATH)" "справка к меню: оценки IMDb ($IMDB_RATINGS_PATH)" install -d -m 0755 "$(dirname "$IMDB_RATINGS_PATH")" # Свежее суток не перекачиваем: выгрузка обновляется раз в день, а качать 8.6 МБ # на каждый прогон установщика незачем. if [ -s "$IMDB_RATINGS_PATH" ] && [ -z "$(find "$IMDB_RATINGS_PATH" -mtime +0)" ]; then skip "$IMDB_RATINGS_PATH ($(wc -l < "$IMDB_RATINGS_PATH") ratings)" "$IMDB_RATINGS_PATH ($(wc -l < "$IMDB_RATINGS_PATH") оценок)" return fi local tmp="$IMDB_RATINGS_PATH.part" # Справка - украшение, а не механизм показа: не скачалось, режется по имени, нет # сети - установка идёт дальше, а меню просто печатается без рейтинга. if ! fetch --max-time 120 "$IMDB_RATINGS_URL" -o "$tmp.gz"; then rm -f "$tmp.gz" info "IMDb export could not be downloaded - the menu will have no ratings; playback is unaffected" "выгрузка IMDb не скачалась - меню будет без рейтинга, на показ не влияет" return fi if ! gzip -dc "$tmp.gz" | awk -F'\t' -v min="$IMDB_MIN_VOTES" \ 'NR==1 || $3+0 >= min { print $1 "\t" $2 "\t" $3 }' > "$tmp"; then rm -f "$tmp" "$tmp.gz" info "IMDb export is damaged - the menu will have no ratings; playback is unaffected" "выгрузка IMDb битая - меню будет без рейтинга, на показ не влияет" return fi mv "$tmp" "$IMDB_RATINGS_PATH" rm -f "$tmp.gz" info "ratings: $(wc -l < "$IMDB_RATINGS_PATH") (at least $IMDB_MIN_VOTES votes)" "оценок: $(wc -l < "$IMDB_RATINGS_PATH") (от $IMDB_MIN_VOTES голосов)" } setup_names() { log "title metadata: Russian IMDb release names ($IMDB_NAMES_PATH)" "паспорт картины: русские прокатные имена IMDb ($IMDB_NAMES_PATH)" install -d -m 0755 "$(dirname "$IMDB_NAMES_PATH")" # Свежее месяца не перекачиваем: 735 МБ на каждый прогон установщика незачем. if [ -s "$IMDB_NAMES_PATH" ] && [ -z "$(find "$IMDB_NAMES_PATH" -mtime +"$IMDB_NAMES_DAYS")" ]; then skip "$IMDB_NAMES_PATH ($(wc -l < "$IMDB_NAMES_PATH") names)" "$IMDB_NAMES_PATH ($(wc -l < "$IMDB_NAMES_PATH") имён)" return fi local tmp="$IMDB_NAMES_PATH.part" local names="$IMDB_NAMES_PATH.akas.part" local basics="$IMDB_NAMES_PATH.basics.part" rm -f "$tmp" "$names" "$basics" # Паспорт - подмога добора, а не механизм показа: не скачалось, нет сети - установка # идёт дальше, а справка отвечает ровно так, как отвечала без карты. if ! fetch --max-time 3600 "$IMDB_AKAS_URL" | gzip -dc | awk -F'\t' \ 'NR>1 && ($4=="RU" || $4=="SU" || $5=="ru") && !seen[$1"\t"$3]++ { print $1 "\t" $3 }' > "$names"; then rm -f "$names" "$basics" info "IMDb names export could not be downloaded - metadata stays unchanged; playback is unaffected" "выгрузка имён IMDb не скачалась - паспорт останется как был, на показ не влияет" return fi # Тип, оригинал и год лежат в другой выгрузке; берём из неё только картины, у которых # нашлось русское имя, и только экранные: эпизоды и игры паспорту не нужны. if ! fetch --max-time 1800 "$IMDB_BASICS_URL" | gzip -dc | awk -F'\t' \ 'NR==FNR { keep[$1]; next } FNR>1 && ($1 in keep) \ && $2!="tvEpisode" && $2!="videoGame" && $5!="1" \ { o=$4; if (o=="\\N") o=$3; print $1 "\t" $2 "\t" o "\t" $6 }' "$names" - > "$basics"; then rm -f "$names" "$basics" info "IMDb title export could not be downloaded - metadata stays unchanged; playback is unaffected" "выгрузка карточек IMDb не скачалась - паспорт останется как был, на показ не влияет" return fi if awk -F'\t' 'NR==FNR { b[$1]=$2 "\t" $3 "\t" $4; next } \ ($1 in b) { print $2 "\t" $1 "\t" b[$1] }' "$basics" "$names" > "$tmp"; then mv "$tmp" "$IMDB_NAMES_PATH" info "names: $(wc -l < "$IMDB_NAMES_PATH")" "имён: $(wc -l < "$IMDB_NAMES_PATH")" else rm -f "$tmp" info "IMDb name map could not be built - metadata stays unchanged; playback is unaffected" "карта имён IMDb не собралась - паспорт останется как был, на показ не влияет" fi rm -f "$names" "$basics" } cleanup_login_notice() { local motd_file="${TORRCAST_MOTD:-/etc/motd}" local motd_d="${TORRCAST_MOTD_D:-/etc/update-motd.d}" rm -f "$motd_d/00-torrcast" if [ -f "$motd_file" ] && grep -qF 'cast status | stop | doctor' "$motd_file"; then : >"$motd_file" fi } # 🔴 TC-988. Права тут больше НЕ поднимаются, и это не забывчивость. `main` бежит # форкнутым работником под заставкой (:func:`ui_spawn`), а поднятие кончается # `exec`, который заменяет работника целиком: канал прогресса за sudo не передаётся, # `[[ -t 1 ]]` у перезапущенного ложь (стдаут работника - файл журнала), и он уходит # лентой в этот файл. Снаружи это выглядело так: полоса стоит на 0 % всю установку, # в конце экран стирается, журнал вываливается лентой, а последней строкой - # «установка оборвалась (код 0, закрыто фаз 0 из 12)» на установке, которая ПРОШЛА. # Поэтому поднятие переехало в точку входа, до заставки (см. самый низ файла). main() { cleanup_login_notice # Локаль и apt идут первыми и по очереди, иначе никак: `curl`, `jq` и `python3-venv` # приезжают именно отсюда, а без них не начать ни одну загрузку. has locale && { setup_locale; phase_done 'locale' 'локаль'; } has packages && { install_packages; phase_done 'packages' 'пакеты'; } # 🔴 Дальше вся работа - сеть, и вся она независима: статическая сборка ffmpeg, два # соседних сервера, пробы трекеров и определения индексеров ничего не знают друг о # друге. По очереди это стоило больше половины установки. Пускаем разом и ждём каждое # там, где его результат впервые нужен; вывод каждого приезжает целиком (:func:`job_start`). JOB_DIR="$(mktemp -d)" has ffmpeg && job_start ffmpeg install_ffmpeg has torrserver && job_start torrserver install_torrserver has sources && job_start sources check_sources has prowlarr && job_start prowlarr install_prowlarr log "in background: ffmpeg, TorrServer, Prowlarr, sources - their complete output will follow" "в фоне: ffmpeg, TorrServer, Prowlarr, источники - их вывод придёт целиком" # Самое долгое (venv и колёса с pypi) держим на переднем плане: пока идёт оно, # соседи успевают скачаться, подняться и ответить. has torrcast && { install_torrcast; phase_done 'torrcast' 'пакет torrcast'; } # Индексерам нужны оба: шим с прибитыми именами и поднятый Prowlarr. # 🔴 TC-885. `phase_done` у фоновых заданий стоит после `job_wait`, а не после # `job_start`: закрытой фаза становится там, где её результат приехал и был # проверен. Поставь её у запуска - и полоса улетала бы в 40 % за первую # секунду, ровно тем враньём, ради ухода от которого прогресс и отвязан от часов. # 🔴 TC-909. Держит это не комментарий, а мера: `tests/test_installbar.py` гоняет # установку с подделанными работниками и требует, чтобы деление каждой фоновой # фазы бралось не раньше, чем её работа кончилась. Перенос строки ниже к # `job_start` красит гейт; меры TC-885 его пропускают, им видна только формула. if has sources; then job_wait sources || info "⚠ source check did not finish - see the lines above" "⚠ проверка источников не доработала - смотри строки выше" phase_done 'sources' 'источники' fi if has prowlarr; then job_wait prowlarr || die "Prowlarr was not installed - see the reason above" "Prowlarr не поставился - причина в строках выше" phase_done 'Prowlarr' 'Prowlarr' fi has indexers && { install_indexers; phase_done 'indexers' 'индексеры'; } has config && { setup_config; setup_bot_unit; setup_ha_unit; phase_done 'config' 'конфиг'; } has hls && { setup_hls; phase_done 'serving' 'раздача'; } has receiver && { setup_receiver; phase_done 'receiver' 'приёмник'; } # Без этих двух `cast` не покажет ничего, поэтому «готово» ждёт их обоих. if has ffmpeg; then job_wait ffmpeg || die "ffmpeg was not installed - see the reason above" "ffmpeg не поставился - причина в строках выше" phase_done 'ffmpeg' 'ffmpeg' fi if has torrserver; then job_wait torrserver || die "TorrServer was not installed - see the reason above" "TorrServer не поставился - причина в строках выше" phase_done 'TorrServer' 'TorrServer' fi # А это первому показу не нужно: оценки украшают меню, и качать их человеку незачем. if has facts; then late_run "IMDb ratings for menu details" "оценки IMDb для справки в меню" setup_facts late_run "Russian IMDb release names for title metadata" "русские прокатные имена IMDb для паспорта картины" setup_names phase_done 'warmup' 'догрев' fi [ -n "$JOB_DIR" ] && rm -rf "$JOB_DIR" # 🔴 TC-887. Закрывающее слово принадлежит РАБОТЕ, а не заставке. Под заставкой его # печатает финальный блок, а без терминала - вот эта строка, и промолчи она про # обновление, человек, обновившийся из скрипта или по ssh, прочёл бы «готово - # смотри: cast» и не узнал бы из вывода, состоялся переход или его просто поставили # заново. if upgrading; then log "torrcast $UPGRADE_FROM → $VERSION updated" "torrcast $UPGRADE_FROM → $VERSION обновлено" else log "done - try: cast " "готово - смотри: cast <название>" fi # «Готово» сказано, когда `cast` и правда может играть. Если что-то ещё догревается - # это отдельная строка ПОСЛЕ него, а не задержка перед ним. if [ -s "$LATE_NOTES" ]; then info "finishing in background: $(awk 'NR>1{printf "; "} {printf "%s", $0} END{print ""}' "$LATE_NOTES") (result in $LATE_LOG)" \ "догрев: $(awk 'NR>1{printf "; "} {printf "%s", $0} END{print ""}' "$LATE_NOTES") (итог в $LATE_LOG)" fi rm -f "$LATE_NOTES" # 🔴 TC-692. Последнее слово установки. Всё своё она поставила, показ работает - но # если роль каталога нести некому, искать нечем, и «готово» без этой строки было бы # неправдой: раньше такая установка молча объявляла успех, а свежая машина не находила # почти ничего. Роняем не установку, а её ВЕРДИКТ: код возврата у неудавшегося # каталога ненулевой (:data:`EXIT_CATALOG_CUT`), иначе всякая автоматика поверит нулю. if [ -n "$CATALOG_CUT_EN" ]; then loud "catalog is incomplete: $CATALOG_CUT_EN" "каталог урезан: $CATALOG_CUT_RU" info "torrcast is installed and playback works, but searches find almost nothing: the network blocks sources. Background retries continue; see cast doctor, or rerun ./install.sh when they respond" \ "torrcast поставлен и показ работает, но искать почти нечего: источники режет сеть. Догрев переспрашивает их сам, состояние видно в cast doctor, повторный ./install.sh заведёт их, когда они ответят" exit "$EXIT_CATALOG_CUT" fi } # ============================================================================= # --- 8. Подача: заставка вместо ленты «==>» --------------------------------- # ============================================================================= # Перенос из референса (tmp-ref/install.sh). Лого отскакивает по рамке в стиле # DVD, снизу статусбар во всю ширину окна, на выходе лого садится в центр, # сводит цвет к фирменному зелёному и под ним остаётся справка. Установщик при # этом продолжает УСТАНАВЛИВАТЬ: заставка - вторая нитка, а не замена работы. # # Alt-screen не берём НАМЕРЕННО. Он возвращает на выход экран, каким тот был до # запуска, и после яркой полноэкранной анимации этот возврат читается как # «непонятный остаток прошлого ввода». Рисуем на основном экране, а на выходе # оставляем готовую справку - её и видит владелец вместо призрака. Побочно это # же спасает покадровый прибор: pyte альтернативного экрана не знает и показал # бы кадр, которого зритель не видел. # # Против мерцания три меры сразу. Кадр собирается в одну строку и уходит одним # write внутри DECSET 2026 (synchronized output), поэтому терминал не рисует # полукадры. Стирается только прямоугольник под прошлым положением лого, а не # всё нутро рамки. Ни одного форка в кадре: время через EPOCHREALTIME, пауза # через read -t, чтение канала прогресса - встроенным read по открытому fd. # 🔴 Всё это - возможности bash 4.1 и 5.0, а штатный интерпретатор мака до сих # пор bash 3.2.57. Ни одной из них там нет, и под `set -u` первое же обращение к # EPOCHREALTIME убивало заставку на первой строке. Ниже у каждой из трёх есть # запасной путь, а развилка идёт по ВЕРСИИ ИНТЕРПРЕТАТОРА (см. UI_HAS_*). # # Перенос авторазворота строк (DECAWM off) - решение уже этой правки, у # референса его нет. Одна перенесённая строка сдвигает весь экран на строку # вверх и уносит верх рамки. Автозаворот выключен: лишнее просто обрезается # краем окна. # --- возможности интерпретатора --- # 🔴 Развилка идёт по ВЕРСИИ BASH, а не по OS_FAMILY, и это разница по существу. # По имени ОС ветка «старый bash» на Linux не исполнилась бы никогда, то есть # была бы непроверяемой: гейт стоит на Linux, и зелень говорила бы только про # bash 5. По версии её гоняет и сторож (bash 3.2 рядом с bash 5 на одной машине), # и владелец на маке под Homebrew-овым bash 5 получает быстрый путь, а не путь # «для мака». Мера тут - то, чем возможность и определяется. #: EPOCHREALTIME - bash 5.0. Пусто - часы виртуальные (:func:`ui_now_ms`). UI_HAS_EPOCH=0 #: Дробный таймаут `read -t` - bash 4.0, дескриптор через `exec {fd}<` - bash 4.1. #: Обе нужны паузе без форка и чтению канала, поэтому порог у них общий. UI_HAS_FDREAD=0 if (( BASH_VERSINFO[0] >= 5 )); then UI_HAS_EPOCH=1; fi if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1) )); then UI_HAS_FDREAD=1 fi # --- терминальные escape --- ESC=$'\033'; CSI="${ESC}[" HIDE="${CSI}?25l"; SHOW="${CSI}?25h" SYNC_ON="${CSI}?2026h"; SYNC_OFF="${CSI}?2026l" WRAP_OFF="${CSI}?7l"; WRAP_ON="${CSI}?7h" CLEAR="${CSI}2J"; HOME_POS="${CSI}H" NC="${CSI}0m"; BOLD="${CSI}1m" DIMW="${CSI}0;2m" VERSION='1.1.4' DEFAULT_DURATION=21 # Шаг на кадр - ЦЕЛОЕ число клеток (2 по колонкам, 1 по строкам). Дробный шаг # раньше давал кадры без движения: при 0.8 клетки каждый пятый кадр целая # позиция не менялась, и это читалось как подвисание. Замедляемся длиной кадра, # а не долями клетки: 2/0.122 = 16.4 колонок/с. FRAME_MS=122 STEP_X=200 STEP_Y=100 #: Сколько миллисекунд холостая подача откладывает себе на посадку. У боевой #: установки посадка идёт ПОСЛЕ работы (конца никто заранее не знает), поэтому #: и у холостой аргумент - это вся длина подачи целиком, а не длина работы: #: работнику остаётся аргумент минус этот запас. Дуга длиной от 4 до 40 кадров, #: то есть от 0.49 до 4.88 с (:func:`ui_land_frames`), и заранее её длину не #: знает никто: она считается от остатка пути в момент, когда работа кончилась. #: Запас взят чуть ниже середины вилки, чтобы вся она укладывалась в аргумент. #: Он привязан к длине кадра: пересчитывать вместе с `FRAME_MS`, иначе посадка #: перестанет умещаться в отведённое ей время и лого будет доезжать рывком. UI_LAND_RESERVE_MS=2800 ui_version() { # VERSION из pyproject.toml, без форка local line f="$REPO_DIR/pyproject.toml" [ -r "$f" ] || return 0 while IFS= read -r line; do case $line in version\ =\ *|version=*) line=${line#*=}; line=${line//\"/}; line=${line//\'/}; line=${line// /} [ -n "$line" ] && VERSION=$line return 0 ;; esac done < "$f" return 0 } #: Правда ли, что это обновление, а не установка. Спрашивать ТОЛЬКО после `ui_version`: #: до неё VERSION - литерал из этого файла, и на свежесобранном тарболе он совпал бы с #: чем угодно. Равенство версий тут значит «обновлять не с чего»: загрузчик до сюда с #: такой парой не доходит вовсе, а прямой зов install.sh с этой переменной остаётся #: обычной идемпотентной переустановкой, а не заставкой про несуществующий переход. upgrading() { [ -n "$UPGRADE_FROM" ] && [ "$UPGRADE_FROM" != "$VERSION" ]; } #: Записи ченджлога версии $1 на языке установки. Читаются из РАСПАКОВАННОГО дерева, а #: не вторым походом в сеть: у анонима на api.github.com 60 запросов в час на адрес, и #: описание релиза стоило бы ровно того же потолка, что и узнавание тега. #: Формат построчный: `[версия]` открывает раздел, `en текст` / `ru текст` - запись. CHANGELOG=() ui_changelog() { # $1 - версия -> CHANGELOG CHANGELOG=() local file="$REPO_DIR/docs/changelog" line inside=0 tag [ -r "$file" ] || return 0 while IFS= read -r line; do case $line in ''|'#'*) continue ;; '['*']') if [ "$line" = "[$1]" ]; then inside=1; else inside=0; fi continue ;; esac (( inside )) || continue tag=${line%% *} [ "$tag" = "$LANGUAGE" ] || continue CHANGELOG+=( "${line#* }" ) done < "$file" return 0 } # --- цвет --- # Truecolor объявляем только по COLORTERM: терминал с 256 цветами, но без # truecolor, распечатал бы escape как мусор. Иначе спускаемся до куба 6x6x6. COLOR_MODE=basic ui_pick_color() { case "${COLORTERM:-}" in truecolor|24bit) COLOR_MODE=truecolor ;; *) case "${TERM:-}" in *direct*) COLOR_MODE=truecolor ;; *256color*|xterm*|screen*|tmux*|rxvt*|alacritty|foot*|kitty*) COLOR_MODE=256 ;; esac ;; esac } FGX='' ui_fg() { # r g b -> escape в FGX local r=$1 g=$2 b=$3 case $COLOR_MODE in truecolor) FGX="${CSI}38;2;${r};${g};${b}m" ;; 256) FGX="${CSI}38;5;$(( 16 + 36 * ((r * 5 + 127) / 255) \ + 6 * ((g * 5 + 127) / 255) \ + ((b * 5 + 127) / 255) ))m" ;; *) FGX="${CSI}$(( 30 + (r > 127) + 2 * (g > 127) + 4 * (b > 127) ))m" ;; esac } R=0; G=0; B=0 ui_hsv() { # h(0..359) s(0..100) v(0..100) -> R G B (0..255) local h=$1 s=$2 v=$3 local i=$(( h / 60 % 6 )) f=$(( h % 60 * 1000 / 60 )) local p=$(( v * (100 - s) / 100 )) local q=$(( v * (100000 - s * f) / 100000 )) local t=$(( v * (100000 - s * (1000 - f)) / 100000 )) case $i in 0) R=$v; G=$t; B=$p ;; 1) R=$q; G=$v; B=$p ;; 2) R=$p; G=$v; B=$t ;; 3) R=$p; G=$q; B=$v ;; 4) R=$t; G=$p; B=$v ;; *) R=$v; G=$p; B=$q ;; esac R=$(( R * 255 / 100 )); G=$(( G * 255 / 100 )); B=$(( B * 255 / 100 )) } BRAND=''; GREEN=''; DIMG=''; EMPTY_COL=''; FRAMEC='' # Фирменный зелёный в числах: к нему сводится цвет лого на посадке, чтобы # итоговый экран подхватил ровно тот же код и стыка не было видно. BR_R=0; BR_G=220; BR_B=0 ui_palette() { ui_fg 0 220 0; BRAND=$FGX GREEN=$BRAND ui_fg 0 120 0; DIMG=$FGX ui_fg 26 62 26; EMPTY_COL=$FGX # Рамка цветом терминала по умолчанию - она белая на тёмной теме и чёрная на # светлой. Зелёная рамка спорила с лого, которое и так перекрашивается. FRAMEC=$NC } # --- баннеры (figlet standard / small / mini) --- # Четыре размера, а не обрезанное лого: обрезанный figlet выглядит поломанным. # Лого без тени: на штриховом figlet-е смещённая на клетку копия читается не как # тень, а как призрак следом за текстом. # shellcheck disable=SC2034 # массивы берутся по имени через nameref в ui_try_banner B_FULL=( ' _ _ ' '| |_ ___ _ __ _ __ ___ __ _ ___| |_ ' '| __/ _ \| '"'"'__| '"'"'__/ __/ _` / __| __|' '| || (_) | | | | | (_| (_| \__ \ |_ ' ' \__\___/|_| |_| \___\__,_|___/\__|' ) # shellcheck disable=SC2034 # массивы берутся по имени через nameref в ui_try_banner B_MID=( ' _ _ ' '| |_ ___ _ _ _ _ __ __ _ __| |_ ' '| _/ _ \ '"'"'_| '"'"'_/ _/ _` (_-< _|' ' \__\___/_| |_| \__\__,_/__/\__|' ) # shellcheck disable=SC2034 # массивы берутся по имени через nameref в ui_try_banner B_TINY=( '_|_ _ ._.__ _. __|_ ' ' |_(_)| |(_(_|_> |_ ' ) B_MICRO=( 'torrcast' ) SPIN=( '⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏' ) # --- справка, которая остаётся на экране после выхода --- # Колонка команд добита пробелами прямо в литерале: в кириллице ${#s} считает # байты, если локаль не UTF-8, поэтому ширину тут никто не измеряет - она задана. # 🔴 Задана - значит обязана совпадать с фактом: этой же цифрой колонка # центруется и ею же решается, влезает ли ярус. Английские `FIN_W=41` (факт 38) # и `FIN_W_M=20` (факт 21) разошлись с фактом ещё до TC-948; второе расхождение # в опасную сторону - строка шире объявленной коробки. Обе выправлены по факту, # и с тех пор равенство сторожит test_installfinal.py. # Дверь в другой язык названа В ТОМ языке, в который ведёт: `English` латиницей # в русской колонке и `русский` кириллицей в английской. Показывается ровно одна # дверь - та, в которую человеку идти; язык установки уже лежит в конфиге # продукта (`.language=$LANGUAGE`), так что колонка и продукт говорят об одном. # В тир M строка про язык НЕ входит: там место ровно на одну команду, и это # `cast --tv` - телевизор нужен продукту, а язык человек только что выбрал сам. FIN_CMD=( 'cast <запрос> ' 'cast --tv ' 'cast stop ' 'cast status ' 'cast --help ' 'cast --en ' ) FIN_TXT=( 'найти и включить на ТВ' 'выбрать телевизор' 'снять каст' 'что играет сейчас' 'все ключи' 'English' ) FIN_W=39 FIN_CMD_S=( 'cast <запрос> ' 'cast --tv ' 'cast --help ' 'cast --en ' ) FIN_TXT_S=( 'включить на ТВ' 'выбрать ТВ' 'все ключи' 'English' ) FIN_W_S=29 FIN_CMD_M=( 'cast --tv ' ) FIN_TXT_M=( 'выбрать ТВ' ) FIN_W_M=20 if [ "$LANGUAGE" = en ]; then FIN_CMD=( 'cast <query> ' 'cast --tv ' 'cast stop ' 'cast status ' 'cast --help ' 'cast --ru ' ) FIN_TXT=( 'find and play on TV' 'choose a TV' 'stop casting' 'show what is playing' 'show all options' 'русский' ) FIN_W=38 FIN_CMD_S=( 'cast <query> ' 'cast --tv ' 'cast --help ' 'cast --ru ' ) FIN_TXT_S=( 'play on TV' 'choose a TV' 'show all options' 'русский' ) FIN_W_S=30 FIN_CMD_M=( 'cast --tv ' ) FIN_TXT_M=( 'choose a TV' ) FIN_W_M=21 fi # --- мерка для ЧУЖИХ строк --------------------------------------------------- # Свои надписи добиты пробелами в литерале и меряться не обязаны. Имя приёмника # приезжает из сети любой длины, и его мерить приходится - а ${#s} в неUTF-8 # локали считает БАЙТЫ, и «Гостиная» вышла бы шириной 16. Поэтому один раз # спрашиваем сам shell, чем он считает, и в байтовом режиме идём по байтам, # засчитывая только ведущие: у UTF-8 продолжение всегда 10xxxxxx. UI_CHARS=0 ui_probe_chars() { local probe='ё' if (( ${#probe} == 1 )); then UI_CHARS=1; else UI_CHARS=0; fi return 0 } ui_probe_chars LEN=0 ui_len() { # $1 - строка -> LEN, длина в ЗНАКОМЕСТАХ if (( UI_CHARS )); then LEN=${#1}; return 0; fi local i n=${#1} v b seen=0 for (( i = 0; i < n; i++ )); do b=${1:i:1} if [ "$b" = "'" ]; then v=39; else printf -v v '%d' "'$b"; fi (( v < 0 )) && v=$(( v + 256 )) (( v < 128 || v > 191 )) && seen=$(( seen + 1 )) done LEN=$seen return 0 } CUT='' ui_cut() { # $1 - строка, $2 - сколько знакомест -> CUT; РЕЖЕТ, а не переносит local s=$1 max=$2 (( max < 0 )) && max=0 if (( UI_CHARS )); then CUT=${s:0:max}; return 0; fi local i=0 n=${#s} v b seen=0 while (( i < n )); do b=${s:i:1} if [ "$b" = "'" ]; then v=39; else printf -v v '%d' "'$b"; fi (( v < 0 )) && v=$(( v + 256 )) if (( v < 128 || v > 191 )); then (( seen == max )) && break seen=$(( seen + 1 )) fi i=$(( i + 1 )) done CUT=${s:0:i} return 0 } # --- колонка последнего экрана у обновления ---------------------------------- # 🔴 TC-887. Рендер итогового экрана ОДИН (`ui_draw_final`), текста в нём два. Обновление # своей заставки не заводит: заведи - и вторая разъехалась бы с первой на первой же # правке геометрии. Меняются ровно колонки и их объявленная ширина, а ширина тут не # литерал, а МЕРКА: записи ченджлога приезжают из файла любой длины, ровно как имя # приёмника, и врать о своей ширине им нечем. UI_TIER_CMD=(); UI_TIER_TXT=(); UI_TIER_W=0 ui_changelog_tier() { # $1 - сколько записей влезает, $2 - знакомест под текст local rows=$1 room=$2 i wide=0 UI_TIER_CMD=(); UI_TIER_TXT=(); UI_TIER_W=0 for (( i = 0; i < ${#CHANGELOG[@]} && i < rows; i++ )); do ui_cut "${CHANGELOG[i]}" "$room" UI_TIER_CMD+=( '· ' ) UI_TIER_TXT+=( "$CUT" ) ui_len "$CUT"; (( LEN > wide )) && wide=$LEN done UI_TIER_W=$(( wide + 2 )) return 0 } ui_changelog_load() { upgrading || return 0 ui_changelog "$VERSION" if (( ${#CHANGELOG[@]} == 0 )); then # 🔴 Пусто - говорим ПУСТО. Показать записи чужой версии значило бы соврать в лицо # ровно там, где человек читает, за что заплатил обновлением. Раздел ченджлога # заводится под конкретную версию, и её же тут спрашивают: поднимется версия, а # раздела под неё не заведут - экран скажет об этом, а не покажет прошлогоднее. if [ "$LANGUAGE" = ru ]; then CHANGELOG=( 'список изменений этой версии не заполнен' ) else CHANGELOG=( 'the changelog for this version is not filled in' ); fi fi return 0 } # 🔴 Ширина записи меряется от РАМКИ, а не назначается числом. Число тут было бы тем же # враньём, что и объявленная ширина колонки: у справки тексты свои и известны заранее, а # записи ченджлога приезжают из файла - на широком экране литерал резал бы их посреди # слова, хотя места хватало. Ярусы отличаются числом СТРОК: узкому экрану нужно меньше # записей, а не более короткие. Зовётся из `ui_layout`, поэтому переживает и resize. ui_final_text() { upgrading || return 0 local room=$(( INNER_W - 8 )) (( room < 12 )) && room=$(( INNER_W - 4 )) (( room < 1 )) && room=1 ui_changelog_tier 6 "$room"; FIN_CMD=( "${UI_TIER_CMD[@]}" ); FIN_TXT=( "${UI_TIER_TXT[@]}" ) FIN_W=$UI_TIER_W ui_changelog_tier 4 "$room"; FIN_CMD_S=( "${UI_TIER_CMD[@]}" ); FIN_TXT_S=( "${UI_TIER_TXT[@]}" ) FIN_W_S=$UI_TIER_W ui_changelog_tier 1 "$room"; FIN_CMD_M=( "${UI_TIER_CMD[@]}" ); FIN_TXT_M=( "${UI_TIER_TXT[@]}" ) FIN_W_M=$UI_TIER_W return 0 } # --- геометрия --- TROWS=24; TCOLS=80 INNER_W=0; INNER_H=0; BOX_H=0 BANNER=(); BANNER_H=0; BANNER_W=0 MAX_X=1; MAX_Y=1; MAX_X100=100; MAX_Y100=100 BAR_W=10; SHOW_VER=1 INNER_TOP=2; INNER_LEFT=2 #: Нижняя граница отрисовки: строка рамки плюс лента `loud`. Пустая строка, #: статусбар и полоса считаются от неё, поэтому лента раздвигает всё разом, а #: без единой строки внимания раскладка байт в байт референсная. UI_BASE=0 ui_read_size() { local sz='' if [[ -r /dev/tty ]]; then sz=$(stty size < /dev/tty 2>/dev/null) || sz=''; fi if [[ -z $sz ]]; then sz=$(stty size 2>/dev/null) || sz=''; fi if [[ -n $sz && $sz == *' '* ]]; then TROWS=${sz%% *}; TCOLS=${sz##* } else TROWS=$(tput lines 2>/dev/null) || TROWS=24 TCOLS=$(tput cols 2>/dev/null) || TCOLS=80 fi # Мусорный размер не должен ломать рендер, поэтому клемпим с двух сторон. (( TROWS > 0 )) 2>/dev/null || TROWS=24 (( TCOLS > 0 )) 2>/dev/null || TCOLS=80 (( TROWS < 8 )) && TROWS=8 (( TCOLS < 20 )) && TCOLS=20 (( TROWS > 200 )) && TROWS=200 (( TCOLS > 400 )) && TCOLS=400 return 0 } ui_try_banner() { # $1 - имя массива; ставит BANNER*, если влезает с запасом на отскок local src=() case "$1" in B_FULL) src=("${B_FULL[@]}") ;; B_MID) src=("${B_MID[@]}") ;; B_TINY) src=("${B_TINY[@]}") ;; B_MICRO) src=("${B_MICRO[@]}") ;; *) return 1 ;; esac local h=${#src[@]} w=0 l for l in "${src[@]}"; do (( ${#l} > w )) && w=${#l}; done (( INNER_W < w + 1 || INNER_H < h + 1 )) && return 1 BANNER=( "${src[@]}" ); BANNER_H=$h; BANNER_W=$w return 0 } ui_layout() { ui_read_size # Рамка на всю ширину; снизу две строки статусбара. BOX_H=$(( TROWS - 3 )) (( BOX_H < 4 )) && BOX_H=4 UI_BASE=$BOX_H INNER_W=$(( TCOLS - 2 )) INNER_H=$(( BOX_H - 2 )) ui_try_banner B_FULL || ui_try_banner B_MID || ui_try_banner B_TINY || ui_try_banner B_MICRO || { BANNER=( "${B_MICRO[@]}" ); BANNER_H=1; BANNER_W=${#B_MICRO[0]} } MAX_X=$(( INNER_W - BANNER_W )); (( MAX_X < 0 )) && MAX_X=0 MAX_Y=$(( INNER_H - BANNER_H )); (( MAX_Y < 0 )) && MAX_Y=0 MAX_X100=$(( MAX_X * 100 )); MAX_Y100=$(( MAX_Y * 100 )) # Статусбар растянут ровно на ширину окна: "[" + бар + "]" + " NNN%". BAR_W=$(( TCOLS - 8 )) (( BAR_W < 1 )) && BAR_W=1 SHOW_VER=1 (( TCOLS < 26 )) && SHOW_VER=0 # Колонка обновления зависит от ширины рамки, поэтому строится ЗДЕСЬ, а не однажды: # иначе после resize ярусы остались бы с шириной прежнего окна. ui_final_text ui_pick_help } HELP_TIER=0; HELP_H=0; HELP_W=0; HELP_GAP=1; HELP_DROP=0 FIN_BX=0; FIN_TOP=0; FIN_HX=0; FIN_HY=0 ui_pick_help() { # Лого садится РОВНО в центр рамки, справка ложится под ним. Ярус справки # выбирается самый крупный из тех, что помещаются под отцентрованным лого: # двигать лого вверх ради справки нельзя, посадка перестанет быть в центр. FIN_BX=$(( (INNER_W - BANNER_W) / 2 )); (( FIN_BX < 0 )) && FIN_BX=0 FIN_TOP=$(( (INNER_H - BANNER_H) / 2 )); (( FIN_TOP < 0 )) && FIN_TOP=0 local room=$(( INNER_H - FIN_TOP - BANNER_H )) HELP_TIER=0; HELP_H=0; HELP_W=0; HELP_GAP=1; HELP_DROP=0 # Подсказку `cast --tv` колонка отдаёт блоку приёмника там, где та же подсказка # у блока и есть весь смысл: строка «выбери сам: cast --tv» и строка # «cast --tv выбрать ТВ» рядом - не две подсказки, а одна и эхо. # У ченджлога подсказки `cast --tv` нет вовсе, и отдавать блоку приёмника ему нечего: # без этой оговорки счёт ярусов отнял бы у колонки строку, которой в ней нет, и # последняя запись молча не доехала бы до экрана. if ! upgrading; then case $UI_RECV_KIND in many|none) HELP_DROP=1 ;; esac fi # Блок приёмника не роскошь: молчать про приёмник экран больше не вправе, # поэтому его ЧЕСТНЫЙ минимум входит в счёт ярусов наравне со справкой, и # ярус уступает ему строки. Минимум, а не полный размер: список из десяти # найденных не вправе стереть колонку команд целиком. ui_recv_want local floor=$MIN local h1=$(( ${#FIN_CMD[@]} - HELP_DROP )) local h2=$(( ${#FIN_CMD_S[@]} - HELP_DROP )) local h3=$(( ${#FIN_CMD_M[@]} - HELP_DROP )) # Каждый ярус пробуется дважды: с отбивкой от лого и без неё. Пустая строка # под лого - украшение, а строка команды и строка про приёмник - смысл, # поэтому украшение уступает им, и уступает ПЕРВЫМ: тир целиком дороже # отбивки. Прежде так умел один тир M; на 24x80 шестая команда (`cast --en`) # вместе с приёмником просит 1 + 6 + 1 = 8 строк против семи под # отцентрованным лого, и без этого правила экран падал бы сразу на тир S. # Лого при этом не двигается ни на строку: центр рамки остаётся центром. local gap for gap in 1 0; do (( INNER_W >= FIN_W + 2 && room >= gap + h1 + floor )) || continue HELP_TIER=1; HELP_H=$h1; HELP_W=$FIN_W; HELP_GAP=$gap; break done if (( ! HELP_TIER )); then for gap in 1 0; do (( INNER_W >= FIN_W_S + 2 && room >= gap + h2 + floor )) || continue HELP_TIER=2; HELP_H=$h2; HELP_W=$FIN_W_S; HELP_GAP=$gap; break done fi # 🔴 У последнего яруса запас другой, и это не подгонка под цифру. Ярусам 1 и S # две свободные колонки по карману: под ними есть куда отступить. Под тиром M # нет ничего, кроме пустого экрана, поэтому спрашивается только то, ВЛЕЗАЕТ ли # строка, - одна свободная колонка, ровно как у лого (`ui_try_banner`: w + 1). # Иначе честная английская ширина (21 вместо 20) забирает у 24-колоночного # терминала единственную команду: 22 >= 23 не выполняется, и колонки нет вовсе. if (( ! HELP_TIER )); then for gap in 1 0; do (( INNER_W >= FIN_W_M + 1 && room >= gap + h3 + floor )) || continue HELP_TIER=3; HELP_H=$h3; HELP_W=$FIN_W_M; HELP_GAP=$gap; break done fi FIN_HX=$(( (INNER_W - HELP_W) / 2 )); (( FIN_HX < 0 )) && FIN_HX=0 FIN_HY=$(( FIN_TOP + BANNER_H + HELP_GAP )) ui_recv_block $(( room - HELP_GAP - HELP_H )) RECV_Y=$(( FIN_HY + HELP_H + RECV_GAP )) return 0 } # --- блок про приёмник ------------------------------------------------------- #: Что установка нашла и прописала. Приезжает каналом из фазы `receiver`, вида #: `set` (найден и прописан либо уже был в конфиге), `mock` (headless-стенд), #: `many` (нашлось несколько) и `none` (не нашлось никого). Пустой вид - фаза #: приёмника не работала вовсе, и экран остаётся ровно таким, каким был. UI_RECV_KIND=''; UI_RECV_NAME=''; UI_RECV_ADDR=''; UI_RECV_DEV=() RECV_LINES=(); RECV_H=0; RECV_GAP=0; RECV_X=0; RECV_Y=0 ui_recv() { # $1 - вид, дальше через табуляцию имя и адрес local kind=${1%%"$UI_TAB"*} rest='' case $1 in *"$UI_TAB"*) rest=${1#*"$UI_TAB"} ;; esac case $kind in set) UI_RECV_KIND='set'; UI_RECV_NAME=${rest%%"$UI_TAB"*}; UI_RECV_ADDR=${rest#*"$UI_TAB"} ;; mock) UI_RECV_KIND=mock ;; none) UI_RECV_KIND=none ;; many) UI_RECV_KIND=many; UI_RECV_DEV=() ;; dev) UI_RECV_DEV+=( "$rest" ) ;; esac return 0 } LINE='' ui_fit() { # $1 голова, $2 имя, $3 хвост -> LINE; режется ИМЯ, а не хвост # Длинное имя приёмника режем, а не переносим: перенос сдвинул бы весь экран на # строку вверх и унёс верх рамки. Резать при этом надо имя - адрес и слово # «настроен» человек обязан увидеть целиком, они и есть ответ. local hw tw room ui_len "$1"; hw=$LEN ui_len "$3"; tw=$LEN room=$(( INNER_W - hw - tw )) (( room < 0 )) && room=0 ui_cut "$2" "$room" LINE="$1$CUT$3" ui_len "$LINE" if (( LEN > INNER_W )); then ui_cut "$LINE" "$INNER_W"; LINE=$CUT; fi return 0 } ui_recv_plain() { # $1 - готовая строка -> LINE, урезанная под рамку LINE=$1 ui_len "$LINE" if (( LEN > INNER_W )); then ui_cut "$LINE" "$INNER_W"; LINE=$CUT; fi return 0 } ui_recv_pick() { # $1..$N - формы одной надписи, от длинной к короткой -> LINE # Без форм звать нечем: `${!#}` при пустом списке вырождается в `${!0}`, то есть # в имя самого установщика, и человек прочитал бы в рамке путь к скрипту. (( $# )) || { LINE=''; return 1; } # У ОСМЫСЛЕННОГО текста запасные короткие формы, а не обрезка на полуслове: # «приёмник не найден: включи телевизор и» - это уже не подсказка, потому что # обрезалось ровно то, что человеку и надо сделать. 🔴 Поэтому последняя форма # там, где надпись про выбор, - сама команда и ничего кроме: на 24x30 колонка # команд у видов `many` и `none` отдана блоку целиком (HELP_DROP), и если # блоку урезать хвост, человек останется вообще без команды. local one for one in "$@"; do ui_len "$one" if (( LEN <= INNER_W )); then LINE=$one; return 0; fi done ui_recv_plain "${!#}" return 0 } ROOM=0 ui_name_room() { # $1 голова, $2 хвост, $3 имя; успех - имя влезает осмысленно local hw tw ui_len "$1"; hw=$LEN ui_len "$2"; tw=$LEN ROOM=$(( INNER_W - hw - tw )) ui_len "$3" # Либо имя влезает целиком, либо от него остаётся хотя бы четыре знака. Одна # буква именем не является: «приёмник Г, адрес ...» не называет ничего. (( ROOM >= LEN || ROOM >= 4 )) } #: Полный размер блока (WANT) и его честный минимум (MIN). Минимум у списка - #: четыре строки: заголовок, ОДНО имя с адресом, счёт остатка и призыв выбрать. #: Меньше нельзя: список без счёта остатка читается как полный, а он таким уже не #: будет, и человек решит, что видел все найденные приёмники. WANT=0; MIN=0 ui_recv_want() { case $UI_RECV_KIND in many) WANT=$(( ${#UI_RECV_DEV[@]} + 2 )) # заголовок, список, призыв MIN=$WANT; (( MIN > 4 )) && MIN=4 ;; '') WANT=0; MIN=0 ;; *) WANT=1; MIN=1 ;; esac return 0 } ui_recv_many() { # $1 - сколько строк можно занять; список найденных local room=$1 n=${#UI_RECV_DEV[@]} head cta shown extra i if [ "$LANGUAGE" = ru ]; then ui_recv_pick 'нашлось несколько приёмников:' 'приёмников несколько:'; head=$LINE ui_recv_pick 'выбрать нужный надо самому: cast --tv' 'выбери свой: cast --tv' 'cast --tv'; cta=$LINE else ui_recv_pick 'several receivers found:' 'receivers found:'; head=$LINE ui_recv_pick 'choose the one you need: cast --tv' 'choose yours: cast --tv' 'cast --tv'; cta=$LINE fi if (( room >= n + 2 )); then shown=$n; extra=0 elif (( room >= 4 )); then shown=$(( room - 3 )); extra=$(( n - shown )) else # Тесно. Остаётся одно, и это не список, а то, что выбор за человеком: список # без счёта остатка прочитался бы как полный, а он таким уже не будет. if [ "$LANGUAGE" = ru ]; then ui_recv_pick "приёмников найдено $n - выбери свой: cast --tv" "приёмников $n: cast --tv" "$n ТВ: cast --tv" 'cast --tv' else ui_recv_pick "found $n receivers - choose yours: cast --tv" "$n receivers: cast --tv" "$n TVs: cast --tv" 'cast --tv'; fi RECV_LINES=( "$LINE" ) return 0 fi RECV_LINES=( "$head" ) for (( i = 0; i < shown; i++ )); do ui_fit ' ' "${UI_RECV_DEV[i]%%"$UI_TAB"*}" " - ${UI_RECV_DEV[i]#*"$UI_TAB"}" RECV_LINES+=( "$LINE" ) done if (( extra > 0 )); then if [ "$LANGUAGE" = ru ]; then ui_recv_plain " и ещё $extra" else ui_recv_plain " and $extra more"; fi RECV_LINES+=( "$LINE" ) fi ui_recv_plain "$cta"; RECV_LINES+=( "$LINE" ) return 0 } ui_recv_set() { # -> LINE: приёмник найден и прописан либо уже стоял в конфиге local head long short if [ "$LANGUAGE" = ru ]; then head='приёмник '; long=", адрес $UI_RECV_ADDR, настроен"; short=" - $UI_RECV_ADDR" else head='receiver '; long=" at $UI_RECV_ADDR is configured"; short=" - $UI_RECV_ADDR" fi if [ -n "$UI_RECV_NAME" ]; then if ui_name_room "$head" "$long" "$UI_RECV_NAME"; then ui_fit "$head" "$UI_RECV_NAME" "$long"; return 0 fi if ui_name_room "$head" "$short" "$UI_RECV_NAME"; then ui_fit "$head" "$UI_RECV_NAME" "$short"; return 0 fi fi # Имени у устройства нет (или его нет в конфиге при повторной установке, где # лежит только адрес), либо от него в рамке не осталось бы и слога. Тогда адрес # и есть всё имя, которое можно назвать честно. # Последняя форма - один адрес: «receiver 192.0.2.10 is» с оборванным хвостом # не говорит ничего, а адрес говорит всё, что в такую ширину влезает. if [ "$LANGUAGE" = ru ]; then ui_recv_pick "приёмник по адресу $UI_RECV_ADDR, настроен" "приёмник $UI_RECV_ADDR настроен" "приёмник $UI_RECV_ADDR" "$UI_RECV_ADDR" else ui_recv_pick "receiver at $UI_RECV_ADDR is configured" "receiver $UI_RECV_ADDR is set" "receiver $UI_RECV_ADDR" "$UI_RECV_ADDR"; fi return 0 } ui_recv_fill() { # $1 - сколько строк можно занять; собрать RECV_LINES RECV_LINES=() case $UI_RECV_KIND in set) ui_recv_set RECV_LINES=( "$LINE" ) ;; mock) if [ "$LANGUAGE" = ru ]; then ui_recv_pick 'приёмник mock: headless-стенд, каста наружу нет' 'приёмник mock: каста наружу нет' 'mock: каста нет' else ui_recv_pick 'receiver mock: headless stand, no casting out' 'receiver mock: no casting out' 'mock: no casting'; fi RECV_LINES=( "$LINE" ) ;; none) if [ "$LANGUAGE" = ru ]; then ui_recv_pick 'приёмник не найден: включи телевизор и выполни cast --tv' 'приёмника нет: включи ТВ, cast --tv' 'нет ТВ: cast --tv' 'cast --tv' else ui_recv_pick 'no receiver found: turn the TV on and run cast --tv' 'no receiver: turn the TV on, cast --tv' 'no TV: cast --tv' 'cast --tv'; fi RECV_LINES=( "$LINE" ) ;; many) ui_recv_many "$1" ;; esac return 0 } ui_recv_block() { # $1 - строк под блок вместе с отбивкой от справки RECV_LINES=(); RECV_H=0; RECV_GAP=0; RECV_X=0 local slots=$1 w=0 one [ -n "$UI_RECV_KIND" ] || return 0 (( slots < 1 )) && return 0 ui_recv_want # Пустая строка между справкой и блоком - только когда блок и без неё влезает # целиком: отбивка красивее, но резать из-за неё список нельзя. (( slots > WANT )) && RECV_GAP=1 ui_recv_fill $(( slots - RECV_GAP )) RECV_H=${#RECV_LINES[@]} if (( RECV_H < 1 )); then RECV_GAP=0; return 0; fi for one in "${RECV_LINES[@]}"; do ui_len "$one"; (( LEN > w )) && w=$LEN done RECV_X=$(( (INNER_W - w) / 2 )); (( RECV_X < 0 )) && RECV_X=0 return 0 } # --- кэш полосы --- # Строки-префиксы считаем один раз на смену размера: в кадре остаётся одна # склейка вместо BAR_W конкатенаций. BAR_PRE=(); BAR_EMP=() ui_build_bar_cache() { # Заливка РОВНО одного цвета. Градиент по яркости, что стоял тут раньше, # читался как неравномерная заливка: начало полосы тусклее конца, и чем шире # окно, тем заметнее. Цвет фирменный - тот же, что у процентов и у лого. local i acc="$GREEN" BAR_PRE=( '' ) for (( i = 0; i < BAR_W; i++ )); do acc+='█' BAR_PRE[i + 1]=$acc done acc='' BAR_EMP=( '' ) for (( i = 0; i < BAR_W; i++ )); do acc+='░'; BAR_EMP[i + 1]=$acc; done return 0 } ui_rep() { # $1 - сколько, $2 - чем local s; printf -v s '%*s' "$1" '' printf '%s' "${s// /$2}" } ui_draw_border() { local buf="${SYNC_ON}${HOME_POS}${FRAMEC}" row local line; line=$(ui_rep "$INNER_W" '─') local blank; printf -v blank '%*s' "$INNER_W" '' buf+="╭${line}╮" for (( row = 0; row < INNER_H; row++ )); do buf+="${CSI}$(( row + INNER_TOP ));1H│${NC}${blank}${FRAMEC}│" done buf+="${CSI}${BOX_H};1H╰${line}╯${NC}" buf+="${CSI}$(( UI_BASE + 1 ));1H${CSI}2K" buf+="$SYNC_OFF" printf '%s' "$buf" } # --- состояние анимации --- px=0; py=0; vx=$STEP_X; vy=$STEP_Y; hue=120 prev_x=0; prev_y=0; prev_valid=0 CUR=''; CR=0; CG=0; CB=0 ui_isqrt() { # целочисленный корень, метод Ньютона; нужен для степени 1.5 в посадке local n=$1 x y (( n < 2 )) && { ISQ=$n; return 0; } x=$(( n / 2 )) while :; do y=$(( (x + n / x) / 2 )) (( y >= x )) && break x=$y done ISQ=$x return 0 } ui_land_frames() { # сколько кадров отвести на гашение текущего остатка пути # Окно посадки берётся ОТ РАССТОЯНИЯ, а не постоянной: на фиксированной секунде # лого с ближней точки приезжало за треть окна и остаток стояло - ровно то # «подвисание», из-за которого переписывали движение. Вход в дугу держим около # крейсерской скорости, отсюда множители: по колонкам ход 2 клетки за кадр, # по строкам 1, а ease-out степени 1.5 стартует втрое-вчетверо от средней. local dx dy n dx=$(( (LAND_X - px) / 100 )); (( dx < 0 )) && dx=$(( -dx )) dy=$(( (LAND_Y - py) / 100 )); (( dy < 0 )) && dy=$(( -dy )) n=$(( dy * 2 )); (( dx > n )) && n=$dx n=$(( n * 3 / 4 )) (( n < 4 )) && n=4 # Потолок дуги. Упереться в него значит войти в дугу быстрее крейсера - рывок # на входе. При 28 кадрах это ловилось уже на 200 колонках, при 40 запаса # хватает до самых широких окон, а дуга не длиннее пяти секунд. (( n > 40 )) && n=40 LAND_N=$n return 0 } ui_clamp_speed() { # Шаг больше хода по оси - лого перепрыгивает грань и в тесной рамке # никогда на ней не оказывается. Подрезаем скорость под ход. local a a=${vx#-}; (( MAX_X100 > 0 && a > MAX_X100 )) && vx=$(( vx > 0 ? MAX_X100 : -MAX_X100 )) a=${vy#-}; (( MAX_Y100 > 0 && a > MAX_Y100 )) && vy=$(( vy > 0 ? MAX_Y100 : -MAX_Y100 )) return 0 } ui_recolor() { # цвет лого; RGB держим отдельно - по ним идёт сведение на посадке ui_hsv "$hue" 88 100 CR=$R; CG=$G; CB=$B ui_fg "$CR" "$CG" "$CB"; CUR=$FGX return 0 } ui_build_status() { # $1 pct $2 frame -> STATUS local pct=$1 fr=$2 local filled=$(( pct * BAR_W / 100 )) (( filled > BAR_W )) && filled=$BAR_W (( filled < 0 )) && filled=0 local empty=$(( BAR_W - filled )) local bar="${BAR_PRE[filled]}" (( empty > 0 )) && bar+="${EMPTY_COL}${BAR_EMP[empty]}" local sp=${SPIN[(fr / 2) % ${#SPIN[@]}]} local left if (( SHOW_VER )) && upgrading; then # Шапка обновления говорит ПЕРЕХОД, а не версию: «version 1.0.1» на экране # обновления не отвечает на единственный вопрос, который тут задают, - откуда и куда. left="${GREEN}${BOLD}${sp}${NC} torrcast ${DIMG}${UPGRADE_FROM} → ${VERSION}${NC}" elif (( SHOW_VER )); then left="${GREEN}${BOLD}${sp}${NC} torrcast ${DIMG}version ${VERSION}${NC}" else left="${GREEN}${BOLD}${sp}${NC} torrcast" fi # Имя закрытой фазы - это и есть расшифровка полосы: видно, ЗА ЧТО насчитан # процент. Имена фаз короткие и заданы тут же, длину никто не меряет: с # выключенным автозаворотом лишнее обрежет край окна, а не перенос строки. if (( TCOLS >= 70 )) && [ -n "$UI_PHASE" ]; then local word; if [ "$LANGUAGE" = ru ]; then word=фаза; else word=phase; fi left+=" ${DIMG}${word} ${UI_DONE}/${UI_TOTAL}: ${UI_PHASE}${NC}" fi local tail printf -v tail ' %s%s%3d%%%s' "$GREEN" "$BOLD" "$pct" "$NC" STATUS="${CSI}$(( UI_BASE + 2 ));1H${CSI}2K${left}" STATUS+="${CSI}$(( UI_BASE + 3 ));1H${CSI}2K${DIMG}[${NC}${bar}${DIMG}]${NC}${tail}" return 0 } ui_render() { # $1 pct $2 frame local buf="$SYNC_ON" k # Стираем ровно прошлый прямоугольник лого. Полная очистка нутра рамки # каждый кадр - это и есть мерцание, которого тут быть не должно. if (( prev_valid )); then for (( k = 0; k < BANNER_H; k++ )); do buf+="${CSI}$(( INNER_TOP + prev_y + k ));$(( INNER_LEFT + prev_x ))H${NC}${ERASE_SPAN}" done fi local x=$(( px / 100 )) y=$(( py / 100 )) buf+="$CUR" for (( k = 0; k < BANNER_H; k++ )); do buf+="${CSI}$(( INNER_TOP + y + k ));$(( INNER_LEFT + x ))H${BANNER[k]}" done prev_x=$x; prev_y=$y; prev_valid=1 ui_build_status "$1" "$2" buf+="${NC}${STATUS}${SYNC_OFF}" printf '%s' "$buf" return 0 } ui_draw_final() { # Итоговый экран: лого уже стоит в FIN_TOP/FIN_BX и уже фирменного зелёного - # посадка привела его именно туда. Дорисовываем справку, лого перерисовываем # тем же цветом в те же клетки, поэтому стыка не видно вовсе. # Раскладку пересчитываем прямо тут: пока шла установка, про приёмник ещё # ничего не было известно, а ярус справки и место под блок считаются от него. # Лого при этом не двигается - FIN_TOP и FIN_BX от приёмника не зависят вовсе, # иначе посадка привела бы его не туда, где он останется. ui_pick_help local buf="$SYNC_ON" k local -a cmds=() txts=() only_cmd=() only_txt=() case $HELP_TIER in 1) cmds=( "${FIN_CMD[@]}" ); txts=( "${FIN_TXT[@]}" ) ;; 2) cmds=( "${FIN_CMD_S[@]}" ); txts=( "${FIN_TXT_S[@]}" ) ;; 3) cmds=( "${FIN_CMD_M[@]}" ); txts=( "${FIN_TXT_M[@]}" ) ;; esac if (( HELP_DROP )); then for (( k = 0; k < ${#cmds[@]}; k++ )); do case ${cmds[k]} in 'cast --tv'|'cast --tv '*) continue ;; esac only_cmd+=( "${cmds[k]}" ); only_txt+=( "${txts[k]}" ) done cmds=( "${only_cmd[@]}" ); txts=( "${only_txt[@]}" ) fi local blank; printf -v blank '%*s' "$INNER_W" '' buf+="$NC" for (( k = 0; k < INNER_H; k++ )); do buf+="${CSI}$(( INNER_TOP + k ));${INNER_LEFT}H${blank}" done buf+="${BRAND}" for (( k = 0; k < BANNER_H; k++ )); do buf+="${CSI}$(( INNER_TOP + FIN_TOP + k ));$(( INNER_LEFT + FIN_BX ))H${BANNER[k]}" done for (( k = 0; k < HELP_H; k++ )); do buf+="${CSI}$(( INNER_TOP + FIN_HY + k ));$(( INNER_LEFT + FIN_HX ))H" buf+="${BRAND}${cmds[k]}${DIMW}${txts[k]}${NC}" done # Куда установка будет кастовать - рядом со справкой команд, тем же столбцом. # Без этой строки человек дочитывал финал и не знал, что приёмник уже прописан. for (( k = 0; k < RECV_H; k++ )); do buf+="${CSI}$(( INNER_TOP + RECV_Y + k ));$(( INNER_LEFT + RECV_X ))H" buf+="${NC}${RECV_LINES[k]}" done # Путь к журналу - последней строкой ВНУТРИ рамки, а не под ней. Под рамкой # его пришлось бы печатать второй строкой после [OK], экран уехал бы на строку # вверх и унёс верх рамки - ровно то, из-за чего итоговая строка одна. if [ -n "$UI_JOURNAL_SHOW" ] && (( INNER_W >= 30 && RECV_Y + RECV_H < INNER_H - 1 )); then # Путь к журналу длины не выбирает - его задаёт mktemp, - и в узкой рамке он # затирал правую грань собой. Мерка под рукой, поэтому режем: рвать рамку # ради конца пути нельзя, а начало его всё равно называет каталог. if [ "$LANGUAGE" = ru ]; then ui_recv_plain "журнал установки: ${UI_JOURNAL}" else ui_recv_plain "installation log: ${UI_JOURNAL}"; fi buf+="${CSI}$(( INNER_TOP + INNER_H - 1 ));${INNER_LEFT}H${DIMW}${LINE}${NC}" fi buf+="${NC}${CSI}$(( UI_BASE + 2 ));1H${CSI}2K${CSI}$(( UI_BASE + 3 ));1H${CSI}2K" buf+="${CSI}$(( UI_BASE + 2 ));1H" buf+="$SYNC_OFF" printf '%s' "$buf" return 0 } NOW_MS=0 #: Виртуальные часы для bash без EPOCHREALTIME. Суб-секундных часов у bash 3.2 нет #: ВОВСЕ: ни встроенной переменной, ни `printf %(...)T` (обе - bash 5.0 и 4.2), а #: единственная встроенная мера времени, SECONDS, считает целые секунды. Форк `date` #: на кадр не годится: на маке это BSD-шный date без миллисекунд, и стоил бы он #: дороже самой отрисовки. Поэтому там время двигает ПАУЗА - ровно на столько, на #: сколько её просили подождать (:func:`ui_nap`). #: Цена названа: на bash 3.2 заставка отстаёт от реального времени на суммарную длину #: отрисовки кадров, и долг этот не гасится - кадр не догоняет расписание, а просто #: сдвигает его. Замер на стенде: подача в 21 с идёт 21,4 с против 20,7 с на bash 5, #: кадров 167 против 169. Взамен шаг остаётся строго равномерным, и дуга посадки со #: сведением цвета выходят даже глаже, чем по настоящим часам, - у них нет джиттера. UI_VNOW_MS=0 ui_now_ms() { local t if (( UI_HAS_EPOCH )); then t=${EPOCHREALTIME}; NOW_MS=$(( ${t%%[.,]*} * 1000 + 10#${t#*[.,]} / 1000 )) else NOW_MS=$UI_VNOW_MS fi } # --- жизненный цикл терминала --- # Эхо на время анимации выключаем, а буфер ввода на выходе выгребаем. Иначе # всё, что владелец нажал за время заставки, вываливается на его приглашение # уже после выхода и выглядит как чужой недонабранный ввод. STTY_SAVED='' ui_tty_grab() { [[ -r /dev/tty ]] || return 0 STTY_SAVED=$(stty -g < /dev/tty 2>/dev/null) || { STTY_SAVED=''; return 0; } stty -echo -icanon min 0 time 0 < /dev/tty 2>/dev/null || : return 0 } ui_tty_release() { [[ -n $STTY_SAVED ]] || return 0 local n=0 # min 0 time 0: read забирает то, что скопилось, и сразу возвращается ни с чем, # когда очередь пуста. Счётчик - страховка от бесконечного цикла. # 🔴 Таймаут тут не украшение, а механизм: `-n` переводит терминал в min 1, и без # `-t` пустая очередь вешает выход навсегда (замерено на обеих версиях). Дробного # таймаута у bash 3.2 нет, а целую секунду `-n` там честно выжидает вхолостую - # поэтому на старом bash берётся `-t 1` БЕЗ `-n`: с min 0 time 0 такой read # выгребает очередь и возвращается сразу, а секунда остаётся потолком на случай, # если stty выше не отработал. Замер: набранное до выхода выгребается обеими # ветками, лишнего ожидания не добавляет ни одна. while (( n < 64 )); do if (( UI_HAS_FDREAD )); then read -r -t 0.02 -n 4096 _ < /dev/tty 2>/dev/null || break else read -r -t 1 _ < /dev/tty 2>/dev/null || break fi n=$(( n + 1 )) done stty "$STTY_SAVED" < /dev/tty 2>/dev/null || : STTY_SAVED='' return 0 } UI_CLEANED=0 ui_cleanup() { (( UI_CLEANED )) && return 0 UI_CLEANED=1 printf '%s' "${SYNC_OFF}${SHOW}${WRAP_ON}${NC}" ui_tty_release # 🔴 Работника не бросаем. По Ctrl-C установка обязана остановиться вместе с # заставкой: иначе она доделается втихую на уже отпущенном терминале, поверх # приглашения владельца, и он об этом не узнает. Сторож-обёртка ловит TERM и # добивает подоболочку установки вместе с её фоновыми заданиями; чужие # долгие внуки (curl, apt) досидят до своего таймаута - дальше нашей рукой # не достать, и это честнее, чем kill по маске имени. if [ -n "$UI_WORKER" ] && (( ! UI_ENDED )); then kill -TERM "$UI_WORKER" 2>/dev/null || : fi # Экран не трём: на нём осталась справка, ради которой всё и затевалось. return 0 } ui_apply_layout() { ui_layout ui_build_bar_cache ui_recolor printf -v ERASE_SPAN '%*s' "$BANNER_W" '' (( px > MAX_X100 )) && px=$MAX_X100 (( py > MAX_Y100 )) && py=$MAX_Y100 (( px < 0 )) && px=0 (( py < 0 )) && py=0 ui_clamp_speed LAND_X=$(( FIN_BX * 100 )); LAND_Y=$(( FIN_TOP * 100 )) # Размер сменили посреди посадки - перецепляем её к текущей точке, конец # всё равно придётся на новый центр. landing=0 prev_valid=0 ui_draw_border return 0 } ERASE_SPAN='' LAND_X=0; LAND_Y=0; landing=0; LAND_N=10; LAND_MS=1; land_ms=0; ISQ=0 sweeping=0; col_ms=0; COL_MS=520; sr0=0; sg0=0; sb0=0 lx0=0; ly0=0 end_ms=0; frame=0; STATUS='' NEED_RESIZE=0 # Пауза без форка: read с таймаутом по дескриптору, где никогда не будет байт. SLEEPFD='' ui_nap() { # $1 - миллисекунды local ms=$1 s (( ms <= 0 )) && return 0 # Пауза - единственное место, где рисовалка знает, что время идёт, поэтому она же # и двигает виртуальные часы (:func:`ui_now_ms`). На bash 5 их никто не читает. UI_VNOW_MS=$(( UI_VNOW_MS + ms )) printf -v s '%d.%03d' $(( ms / 1000 )) $(( ms % 1000 )) # Версия названа ЗДЕСЬ, а не только там, где заводится дескриптор: дробный таймаут - # это возможность bash 4.0, и связь «дробный read -t <-> версия» не имеет права # держаться на памяти читателя через третью переменную. Проверка тут вдобавок # бесплатная: пустой SLEEPFD на старом bash и так уводит на `sleep`. if (( UI_HAS_FDREAD )) && [[ -n $SLEEPFD ]]; then read -t "$s" -u "$SLEEPFD" -r _ 2>/dev/null else sleep "$s" fi return 0 } ui_open_sleepfd() { # 🔴 TC-885. Скобки тут не для красоты. У `exec` БЕЗ команды перенаправление # становится постоянным для всей оболочки, поэтому `exec ... 2>/dev/null` # (так было в референсе, и там это ничего не стоило) навсегда уводил stderr # УСТАНОВКИ в /dev/null - вместе с каждым `die` и `loud`. Ровно тот молча # проглоченный `die`, против которого поставлена вся эта глава. Куплено # прогоном на чистом стенде: установка обрывалась на 2 фазе из 12 и не # говорила ни слова. Группа `{ ...; }` глушит только сам `exec`, а stderr # оболочки остаётся на терминале. # 🔴 Без дробного `read -t` дескриптор паузе не нужен вовсе: таймаут «0.122» старый # bash отбивает как invalid timeout specification, и пауза выродилась бы в busy-loop. # Там пауза идёт форком `sleep`, и цена названа замером, а не оценкой: подача в 10 с # под strace дала 60 запусков /usr/bin/sleep на кадровую паузу 122 мс (потолок 8 в # секунду, наяву 6 - кадр стоит ещё и отрисовки). На bash 5 их в том же цикле ноль. # Обещание «ни одного форка в кадре» на bash 3.2, стало быть, не держится. # Дешевле у 3.2 нет ничего: своего `sleep` он не умеет, а любой другой ждущий # примитив там либо целосекундный, либо тот же форк. local rc=0 SLEEPFD='' if (( UI_HAS_FDREAD )); then { exec {SLEEPFD}<> <(:); } 2>/dev/null || SLEEPFD='' # Проверяем, что пауза действительно ждёт. Если дескриптор отдаёт EOF сразу, # цикл превратится в busy-loop на всю длительность - уходим на sleep. # 🔴 Спрашиваем сам read, а не часы: код больше 128 у него значит «истёк таймаут», # а единица - «EOF», и это ровно тот вопрос, который тут задан. Часами эта проба # мерить не может: под виртуальными часами (:func:`ui_now_ms`) она двигала бы их # своей же паузой и отвечала бы «годен» даже на мёртвом дескрипторе. if [ -n "$SLEEPFD" ]; then read -t 0.05 -u "$SLEEPFD" -r _ 2>/dev/null || rc=$? (( rc > 128 )) || SLEEPFD='' fi fi return 0 } # --- канал прогресса на стороне рисовалки ------------------------------------ UI_TOTAL=0; UI_DONE=0; UI_PHASE='' UI_FINAL_NOTES=() UI_DIED=0; UI_ENDED=0; UI_RC=0 #: Отпечаток установленного пакета, каким его посчитала сверка venv ↔ репа #: (:func:`verify_torrcast`). Пусто - сверка не отработала, и врать про неё нечем. UI_SUM='' UI_SUM_TAIL='' #: Текст `die` держим отдельно от журнала НАМЕРЕННО. Фазы идут фоном, и своё #: «ошибка: ...» такая фаза пишет в журнал СВОЕГО задания; в общий журнал он #: выливается только когда до неё доберётся `job_wait`. Развал наступает раньше, #: поэтому опора на журнал дала бы экран без причины - ровно проглоченный `die`. UI_DIE_MSG='' #: Сколько ждём работника после `die`, прежде чем разваливать анимацию. Не ради #: полосы, а ради строк: за эту паузу `job_wait` успевает вылить журнал задания. UI_DIE_GRACE=2500 # Хвост помещается на один обычный экран вместе с повторённой причиной и путём. UI_LOG_TAIL=20 UI_WORKER=''; UI_JOURNAL=''; UI_JOURNAL_SHOW=''; UI_CFD=0 #: Все фазы, которые установка умеет закрывать. Из них берётся ЗНАМЕНАТЕЛЬ: #: сколько включено в PHASES, столько и делений у полосы. Считаем по этому #: списку, а не по словам в PHASES: чужое слово в TORRCAST_PHASES раздуло бы #: знаменатель, и полоса не добралась бы до конца на честной установке. UI_ALL_PHASES='locale packages ffmpeg torrcast torrserver sources prowlarr indexers config hls receiver facts' ui_count_phases() { local p UI_TOTAL=0 for p in $UI_ALL_PHASES; do has "$p" && UI_TOTAL=$(( UI_TOTAL + 1 )) done (( UI_TOTAL < 1 )) && UI_TOTAL=1 return 0 } ui_drain() { # вычитать всё, что приехало в канал; без единого форка local line tag rest while IFS= read -r -u "$UI_CFD" line; do tag=${line%% *}; rest=${line#* } case $tag in # 🔴 TC-922. В след едет ПАРА «деление, имя фазы» - та самая, которую # человек читает в строке статуса («фаза 3/6: источники»). Мера, читающая # из следа один номер, держит связь «номер -> фаза» своей таблицей, и стоит # порядку `phase_done` в `main` разойтись с таблицей, как она сверяет время # одной фазы с работой другой - молча и зелено. Кадр для этого не годится: # два закрытия внутри одного кадра оставляют на экране только последнее. P) UI_DONE=$(( UI_DONE + 1 )); UI_PHASE=$rest ui_trace "CLOSE done=$UI_DONE total=$UI_TOTAL phase=$UI_PHASE" ;; A) UI_FINAL_NOTES+=( "$rest" ) ;; R) ui_recv "$rest" ;; D) UI_DIED=1; UI_DIE_MSG=$rest ;; S) UI_SUM=$rest ;; E) UI_RC=$rest; UI_ENDED=1 ;; esac done return 0 } ui_spawn() { # $1 - имя функции-работника local what="$1" ( # 🔴 Внутренний форк уходит под ЖИВЫМ errexit: рисовалка снимает `-e` только # у себя и только ПОСЛЕ этого места. Сторож ниже снимает `-e` себе, иначе # `wait` с ненулевым кодом убил бы его раньше строки «E» и рисовалка ждала # бы конца, о котором ей никто не скажет. Ни одного `||` вокруг самой # установки: он выключил бы errexit на всю её глубину (TC-638). local inner "$what" >"$UI_JOURNAL" 2>&1 </dev/null & inner=$! trap 'kill -TERM "$inner" 2>/dev/null || :; pkill -TERM -P "$inner" 2>/dev/null || :; exit 143' TERM set +e wait "$inner" printf 'E %s\n' "$?" >>"$UI_CHANNEL" ) & UI_WORKER=$! return 0 } ui_dry_worker() { # Холостая подача. Настоящих фаз нет, но КАНАЛ тот же, `phase_done` тот же и # знаменатель тот же, поэтому покадровый прибор меряет ровно тот тракт, # который потом работает на боевой установке, а не отдельную рисовалку. local i ms s ms=$(( UI_DRY_MS / UI_TOTAL )) (( ms < 1 )) && ms=1 for (( i = 1; i <= UI_TOTAL; i++ )); do printf -v s '%d.%03d' $(( ms / 1000 )) $(( ms % 1000 )) sleep "$s" phase_done "probe $i" "проба $i" done return 0 } ui_die_settle() { # после `die` дать работнику досказать, но не ждать вечно local deadline ui_now_ms; deadline=$(( NOW_MS + UI_DIE_GRACE )) while (( ! UI_ENDED )); do ui_now_ms (( NOW_MS >= deadline )) && break ui_nap 40 ui_drain done return 0 } ui_collapse() { # анимация разваливается: показать хвост журнала ui_cleanup # 🔴 Тут экран трём НАМЕРЕННО, в отличие от удачного выхода. Справки не будет, # а недорисованный кадр под журналом прятал бы причину: анимация не вправе # съесть строку ошибки, и последнее, что видит владелец, обязано быть ею. printf '%s' "${CLEAR}${HOME_POS}" [ -s "$UI_JOURNAL" ] && tail -n "$UI_LOG_TAIL" "$UI_JOURNAL" if [ "$LANGUAGE" = ru ]; then printf 'журнал установки: %s\n' "$UI_JOURNAL" else printf 'installation log: %s\n' "$UI_JOURNAL" fi if (( UI_DIED )); then # Причина печатается ВСЕГДА и последней строкой, даже если она уже есть в # журнале выше: журнал может быть обрезан на полуслове, а спрятать причину # анимация не вправе. local word; if [ "$LANGUAGE" = ru ]; then word=ошибка; else word=error; fi printf '%s%s:%s %s\n' "${CSI}31m" "$word" "$NC" "$UI_DIE_MSG" >&2 else # Работник упал не через `die` (голый errexit, сигнал, недобранная фаза) - # молчать про это нельзя тем более. if [ "$LANGUAGE" = ru ]; then printf '%sошибка:%s установка оборвалась (код %s, закрыто фаз %s из %s) - причина в строках выше\n' \ "${CSI}31m" "$NC" "$UI_RC" "$UI_DONE" "$UI_TOTAL" >&2 else printf '%serror:%s installation broke off (code %s, %s of %s phases closed) - the reason is in the lines above\n' \ "${CSI}31m" "$NC" "$UI_RC" "$UI_DONE" "$UI_TOTAL" >&2 fi fi return 0 } ui_trace() { # $1.. - строка в след, если след включён; чем меряется полоса [ -n "$UI_TRACE" ] || return 0 printf '%s %s\n' "$NOW_MS" "$*" >>"$UI_TRACE" 2>/dev/null || : return 0 } UI_TRACE="${TORRCAST_UI_TRACE:-}" UI_DRY_MS=0 ui_run() { # $1 - dry|real, $2 - секунды для dry local mode="$1" ui_version # Текст ченджлога читается ОДНАЖДЫ, а колонки из него строит раскладка # (`ui_layout`): ширина записи зависит от рамки, а рамка переживает resize. ui_changelog_load ui_pick_color ui_palette ui_count_phases UI_CHANNEL="$(mktemp -t torrcast-ui.XXXXXX)" UI_JOURNAL="${TORRCAST_INSTALL_LOG:-$(mktemp -t torrcast-install.XXXXXX)}" : >"$UI_CHANNEL" : >"$UI_JOURNAL" if [[ $mode == dry ]]; then local dur=${2:-$DEFAULT_DURATION} [[ $dur =~ ^[0-9]+$ ]] && (( dur >= 1 )) || dur=$DEFAULT_DURATION (( dur > 3600 )) && dur=3600 UI_DRY_MS=$(( dur * 1000 - UI_LAND_RESERVE_MS )) (( UI_DRY_MS < dur * 300 )) && UI_DRY_MS=$(( dur * 300 )) else UI_JOURNAL_SHOW=1 fi # Работник форкается ДО того, как рисовалка снимет с себя errexit. if [[ $mode == dry ]]; then ui_spawn ui_dry_worker; else ui_spawn main; fi # 🔴 `set -e` в цикле рендера вреден: цикл состоит из (( )) и read -t, которые # штатно возвращают ненулевой код (значение выражения 0 либо таймаут чтения). # С -e рисовалка падала бы на ровном месте. Установка при этом идёт в своей # подоболочке под живым errexit - снятие -e тут её не касается. set +e # Номер дескриптора выдаёт сам bash - там, где он это умеет (4.1). Старому его # называют руками: девятка свободна, а `read -u` был у него и до того. if (( UI_HAS_FDREAD )); then exec {UI_CFD}<"$UI_CHANNEL" else exec 9<"$UI_CHANNEL" UI_CFD=9 fi ui_tty_grab trap ui_cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM trap 'NEED_RESIZE=1' WINCH printf '%s' "${HIDE}${WRAP_OFF}${CLEAR}${HOME_POS}" ui_apply_layout px=$(( MAX_X100 / 3 )); py=$(( MAX_Y100 / 3 )) ui_open_sleepfd ui_now_ms local deadline=$NOW_MS pct local bounced lt lu le ct cu ce local last=0 frame=0 while :; do if (( NEED_RESIZE )); then NEED_RESIZE=0 printf '%s' "${CLEAR}${HOME_POS}" ui_apply_layout fi ui_drain if (( UI_DIED )); then ui_die_settle ui_collapse # 🔴 Ноль тут был бы зелёным враньём, и `${UI_RC:-1}` от него НЕ спасает: # `:-` подменяет пустоту, а `UI_RC` инициализирован нулём. Куплено прогоном # на чистом стенде: фоновая фаза умерла через `die`, `main` ещё стоял на её # `job_wait` и доложиться не успел - оборванная установка вернула ssh 0. (( UI_ENDED && UI_RC != 0 )) && return "$UI_RC" return 1 fi ui_now_ms if (( landing )); then (( NOW_MS >= end_ms )) && last=1 elif (( UI_ENDED )); then # Работа кончилась - только теперь конец подачи известен. У боевой # установки его заранее не знает никто, поэтому посадка не вписывается в # отведённое время (так делал референс по своим часам), а НАЧИНАЕТСЯ от # события «все фазы закрыты» и длится ровно столько, сколько нужно дуге. if (( UI_RC != 0 && UI_RC != EXIT_CATALOG_CUT )) || (( UI_DONE < UI_TOTAL )); then # Работник кончился, а установка не доделана. Показать тут справку # значило бы соврать зелёным экраном; разваливаемся и показываем журнал. ui_collapse; return "$UI_RC" fi ui_land_frames landing=1 # Отсчёт цепляем к ПРЕДЫДУЩЕМУ кадру, иначе первый кадр посадки выпадает # на lt=0 и лого разок стоит на месте. land_ms=$(( NOW_MS - FRAME_MS )) end_ms=$(( NOW_MS + LAND_N * FRAME_MS )) LAND_MS=$(( end_ms - land_ms )) (( LAND_MS < FRAME_MS )) && LAND_MS=$FRAME_MS lx0=$px; ly0=$py # Цвет сводится к фирменному по своему окну, но не длиннее дуги: иначе на # короткой дуге сведение не успевало бы, и стык с итоговым экраном # пришлось бы гасить рывком. COL_MS=520 (( COL_MS > LAND_MS )) && COL_MS=$LAND_MS col_ms=$(( end_ms - COL_MS )) fi if (( ! sweeping )) && (( landing )) && (( NOW_MS >= col_ms )); then sweeping=1 sr0=$CR; sg0=$CG; sb0=$CB fi if (( landing )); then # Посадка. В конце лого не отскакивает, а по замедляющейся дуге сходится # ровно в ту клетку, где останется на итоговом экране, и заодно сводит # свой цвет к фирменному зелёному. ease-out степени 1.5: на входе # скорость близка к крейсерской, на выходе ноль, поэтому остановка # читается как торможение, а не как обрыв. Цель берётся из раскладки, так # что она верна для любого размера окна, а не подогнана под один. lt=$(( (NOW_MS - land_ms) * 1000 / LAND_MS )) (( lt > 1000 )) && lt=1000 (( lt < 0 )) && lt=0 lu=$(( 1000 - lt )) # ease-out степени 1.5: le = 1 - (1-t)^1.5, в целых через корень. # Кубический тормозил слишком рано и оставлял десяток стоячих кадров в конце. ui_isqrt $(( lu * 1000 )) le=$(( 1000 - lu * ISQ / 1000 )) px=$(( lx0 + (LAND_X - lx0) * le / 1000 )) py=$(( ly0 + (LAND_Y - ly0) * le / 1000 )) else # Шаг с посадкой НА стену, а не отражением от неё. Зеркальное отражение # уводило лого на долю клетки назад, и кадр удара доставался старому цвету - # ровно то, из-за чего первый удар выглядел «без смены цвета». В узкой рамке # оно вдобавок ни разу не попадало в крайнюю клетку и лого казалось стоячим. # Здесь лого ровно один кадр стоит на грани и уже новым цветом: цвет меняем # ДО отрисовки этого же кадра. bounced=0 if (( MAX_X100 > 0 )); then px=$(( px + vx )) if (( px >= MAX_X100 )); then px=$MAX_X100; vx=$(( -vx )); bounced=1 elif (( px <= 0 )); then px=0; vx=$(( -vx )); bounced=1 fi fi if (( MAX_Y100 > 0 )); then py=$(( py + vy )) if (( py >= MAX_Y100 )); then py=$MAX_Y100; vy=$(( -vy )); bounced=1 elif (( py <= 0 )); then py=0; vy=$(( -vy )); bounced=1 fi fi if (( bounced && ! sweeping )); then hue=$(( (hue + 79) % 360 )); ui_recolor; fi fi if (( sweeping )); then # Сведение цвета к фирменному зелёному той же кривой, что и подход: к концу # прогона лого уже ровно того цвета, каким его нарисует итоговый экран, # поэтому стыка между анимацией и справкой не видно. ct=$(( (NOW_MS - col_ms) * 1000 / COL_MS )) (( ct > 1000 )) && ct=1000 (( ct < 0 )) && ct=0 cu=$(( 1000 - ct )) ui_isqrt $(( cu * 1000 )) ce=$(( 1000 - cu * ISQ / 1000 )) # 🔴 Внутри цикла цвет НЕ доводится до фирменного до конца: последний шаг # к нему делает только кадр посадки. Без этой отсечки последний кадр дуги # и кадр посадки округлялись в один и тот же цвет всякий раз, когда # джиттер ставил их вплотную, а место к тому времени уже занято, - и # выходил мёртвый хвост. Ждать, что джиттер не придёт, нельзя: land.py # краснел то на 300x50, то на 60x20, то на 24x9, каждый прогон на новом # размере. 950 берётся из арифметики, а не на глаз: насыщенность 88 при # яркости 100 держит каждый канал в 30..255, поэтому канал R отстоит от # фирменного нуля не меньше чем на 30, и 5 % остатка - это не меньше # единицы даже при самом близком к зелёному оттенке. (( ce > 950 )) && ce=950 CR=$(( sr0 + (BR_R - sr0) * ce / 1000 )) CG=$(( sg0 + (BR_G - sg0) * ce / 1000 )) CB=$(( sb0 + (BR_B - sb0) * ce / 1000 )) ui_fg "$CR" "$CG" "$CB"; CUR=$FGX fi if (( last )); then # 🔴 Итоговое состояние - место посадки, фирменный цвет и сотня - рисуется # РОВНО одним кадром, и этот кадр последний в дуге, а не добавочный за # циклом. Добавочный совпадал с последним кадром дуги всякий раз, когда # джиттер ставил тот вплотную к концу окна: лого два кадра подряд стояло # на месте и уже зелёное. Куплено прогоном land.py: 100x30 и 200x40 # краснели «мёртвых 1» через раз, а 300x50 краснел не всегда - плавающая # краснота и есть подпись джиттера. px=$LAND_X; py=$LAND_Y CR=$BR_R; CG=$BR_G; CB=$BR_B; ui_fg "$CR" "$CG" "$CB"; CUR=$FGX fi # 🔴 Единственный источник процента - закрытые фазы. Ни часов, ни оценок, # ни сглаживания: цена деления 1/N, и промежуточных значений у полосы нет. # Сотню держим до конца работы: 100 % рисуется ОДИН раз, уже после того, # как работник сказал «кончил», - последним кадром дуги. pct=$(( UI_DONE * 100 / UI_TOTAL )) (( pct > 99 )) && pct=99 (( last )) && pct=100 ui_render "$pct" "$frame" ui_trace "FRAME pct=$pct done=$UI_DONE total=$UI_TOTAL" (( last )) && break frame=$(( frame + 1 )) deadline=$(( deadline + FRAME_MS )) ui_now_ms # Кадр не успел - не копим долг, иначе после лага пойдёт очередь мгновенных # кадров и лого дёрнется через полэкрана. (( deadline < NOW_MS )) && deadline=$NOW_MS ui_nap $(( deadline - NOW_MS )) done # Кадр посадки держится на экране паузой, а не повторной отрисовкой: повтор # был бы тем самым мёртвым кадром. ui_nap 350 ui_draw_final # Строка не должна переноситься: перенос выталкивает верх рамки за экран # и справка, ради которой всё затевалось, уезжает вверх. # Порог тут не число, а МЕРКА: строка обновления короче строки установки, и общий # литерал молча отнимал бы у неё слова там, где места хватало. Меряется то, что # печатается, - иначе первая же правка текста разошлась бы с порогом и не сказала. if upgrading; then if [ "$LANGUAGE" = ru ]; then FINAL_SAY="torrcast $UPGRADE_FROM → $VERSION обновлено." else FINAL_SAY="torrcast $UPGRADE_FROM → $VERSION updated successfully." fi # 🔴 Отпечаток приехавшего пакета - в ТОЙ ЖЕ строке, а не второй под ней. Вторая # строка уносит верх рамки за экран ровно так же, как перенос: рамка ростом в экран, # и всё напечатанное под ней сдвигает её вверх (см. ui_draw_final, где ради этого же # путь к журналу оставлен ВНУТРИ рамки). Куплено кадром: со второй строкой у # обновления пропадала верхняя грань, у установки оставалась - и заметил это не # глаз, а покадровый прибор. Мерка та же, что у самой строки: не влезло - отпечаток # остаётся в журнале, а рамка целой. UI_SUM_TAIL='' if [ -n "$UI_SUM" ]; then ui_len "[OK] $FINAL_SAY sha256 $UI_SUM" (( LEN <= TCOLS )) && UI_SUM_TAIL=" sha256 $UI_SUM" fi ui_len "[OK] $FINAL_SAY" (( LEN > TCOLS )) && FINAL_SAY="torrcast $UPGRADE_FROM → $VERSION" printf '%s%s[OK]%s %s%s%s%s\n' "$BRAND" "$BOLD" "$NC" "$FINAL_SAY" "$DIMW" "$UI_SUM_TAIL" "$NC" elif (( TCOLS >= 44 )); then # Итоговое слово обновления - «обновлено», и оно двуязычно: заставку обновления # человек видит уже на языке своей настройки, и одно английское слово в самом конце # читалось бы обрывом. Переход стоит тут же: строка обязана отвечать «откуда куда» # и без заставки - под `TORRCAST_PLAIN`, в ssh из скрипта и в записи прогона. printf '%s%s[OK]%s torrcast %s installed successfully.\n' "$BRAND" "$BOLD" "$NC" "$VERSION" else printf '%s%s[OK]%s torrcast %s\n' "$BRAND" "$BOLD" "$NC" "$VERSION" fi local note # 🔴 bash 3.2 на маке зовёт пустой массив под `set -u` несвязанным, поэтому # раскрытие ограждено. Пустым он бывает штатно: на повторной установке правило # sudoers уже лежит, и setup_cast_sudoers уходит через skip, ничего не пометив. for note in ${UI_FINAL_NOTES[@]+"${UI_FINAL_NOTES[@]}"}; do if [ "$LANGUAGE" = ru ]; then printf '%sвнимание:%s %s\n' "${CSI}1;33m" "$NC" "$note" else printf '%swarning:%s %s\n' "${CSI}1;33m" "$NC" "$note" fi done ui_cleanup rm -f "$UI_CHANNEL" return "$UI_RC" } # --- Точка входа ------------------------------------------------------------- # Три ветки, и все три обязаны работать. # * голое число первым аргументом - ХОЛОСТАЯ подача на столько секунд. Ничего # не ставится, но канал прогресса, знаменатель, полоса, посадка и справка # идут ТЕМ ЖЕ кодом, что и на боевой установке: отличается только работник, # который вместо фаз спит. Ею гоняется сторож совместимости со старым bash # (tests/test_installold.py): рисовалку надо прогнать целиком, а песочницы # под настоящую установку у неё для этого просить незачем. # * не терминал либо TORRCAST_PLAIN - установка лентой «==>», как была. Важно # для ssh из скрипта и для CI: рисовать там некуда, а ставить надо. # * иначе - установка под заставкой. # # 🔴 TC-988. Права поднимаются ЗДЕСЬ, до заставки, и это не косметика порядка. # Поднятие кончается `exec` (:func:`become_root`), а `exec` заменяет процесс # целиком - вместе со всем, что рисовалка успела завести. Стоя внутри `main`, то # есть внутри форкнутого работника (:func:`ui_spawn`), оно уносило работника, а с # ним канал прогресса (`UI_CHANNEL` за sudo не передаётся: едут только TORRCAST_*) # и терминал (стдаут работника - файл журнала, поэтому у перезапущенного `[[ -t 1 ]]` # ложь, и он уходил лентой в этот файл). Заставка при этом оставалась жива и мерила # пустой канал: полоса стояла на 0 % всю установку, а в конце ложно говорила # «установка оборвалась (код 0, закрыто фаз 0 из 12)» - на установке, которая ПРОШЛА. # До заставки `exec` не уносит ничего: заставки ещё нет, терминал у процесса свой, и # перезапущенный под root заход заводит канал уже у себя. # Куплено живым прогоном обычным пользователем; приёмка мерила `sudo ./install.sh`, # где `become_root` возвращался сразу и `exec` не случался вовсе - то есть мерила # не тот вход, которым пользуется человек. Однострок идёт ровно этой веткой. # Холостая подача root не требует: она ничего не ставит, и поднятие ей ни к чему. if [ -n "$UI_DRY_SECONDS" ]; then if [[ ! -t 1 ]]; then # Не терминал - анимировать некуда и незачем, отдаём две строки текста. ui_version printf 'torrcast %s: installing...\n' "$VERSION" printf '[OK] torrcast installed successfully.\n' exit 0 fi ui_run dry "$UI_DRY_SECONDS" else become_root if [[ -t 1 && -z ${TORRCAST_PLAIN:-} ]]; then ui_run real else main "$@" fi fi