- условие самообновления — доменный предикат store.State.IsObservable() вместо фазы catched; один поллер на поверхность, интервалы 5 с и 15 с - отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели вместо 404/500, который htmx не свопит - заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел «Живой поллинг» в конвенции веб-UI
21 KiB
Архитектура
Обзор: как сложено и где что работает. Поведение системы здесь не
описывается — его нормативный дом openspec/specs/; ниже компоненты только
ссылаются на свои capability. Инварианты и их severity — в
CLAUDE.md, схема хранилища — в database.md,
периметр — в security.md.
Принципы
- Один статический бинарь. Доставка — образом с готовым бинарём внутри. См. ADR-2026-06-13-go-single-binary.
- Источник неприкосновенен. Только
mkdir,link(2)иunlinkсвоих целевых ссылок. См. ADR-2026-06-13-hardlinks. - Выход распознавания недоверенный. Безопасность держится на валидации целевого пути, а не на промпте — security.md.
- Единое ядро, тонкие транспорты. Логика приёма — в use-case
ingest; переходами состояний владеетworker. HTTP API, веб-UI, Telegram и CLI лишь складывают команды,workerих сериализует. - Опциональные внешние зависимости. Метабазы (TMDB/TVDB/TVMaze) и триггер Jellyfin включаются конфигом; без них сервис работает, но авто-раскладка без подтверждённого матча не делается — ADR-2026-06-13-auto-link-requires-db-match.
- Минимум компонентов. В духе umbar — без зоопарка сервисов.
Компоненты
cmd/jellybit — точка входа и сборка зависимостей; всё остальное — internal/*.
| Пакет | Ответственность | Capability |
|---|---|---|
ingest |
use-case приёма загрузки, общий для всех транспортов | ingest |
magnet, torrent |
разбор magnet-ссылки и байтов .torrent, извлечение инфохэшей |
ingest |
worker |
владелец машины состояний: поллинг qBittorrent, сериализация команд, фоновая сверка | download-tracking, state-reconciliation, live-status |
qbt |
клиент qBittorrent WebUI API (сессия, добавление, опрос, удаление) | download-tracking |
recognize |
пред-парс имени, вызов LLM, разбор плана, модель уверенности | recognition |
llm |
провайдер LLM за интерфейсом (дискриминатор [llm].type) |
recognition |
metadata |
интерфейс метабаз + TMDB/TVDB/TVMaze (опц.) | metadata-match |
naming |
единая логика целевых имён и отображаемого имени раздачи | file-layout, ingest |
layout |
санитизация путей, хардлинкер, copy-fallback, undo, владение путём | file-layout, state-reconciliation |
store |
SQLite: загрузки, распознавания, подсказки, кандидаты, ссылки | identity |
ident |
генерация и нормализация ULID | identity |
httpapi |
REST + веб-UI на htmx (server-rendered партиалы) | web-ui, review |
tgbot |
Telegram: приём, парсер сообщений торрент-бота, карточки, пинги | notifications, review |
jellyfin |
триггер пересканирования медиатеки (опц.) | file-layout |
config |
загрузка и валидация TOML на старте | — |
logging, logctx |
slog-настройка и протяжка корреляции через контекст | identity |
archrules |
собственный анализатор архитектурных правил (часть гейта) | — |
Транспорты приёма — HTTP API, веб-UI, Telegram и CLI (jellybit add) — ведут в
один ingest; действия пользователя идут командами к worker. Перечень команд
и их эффекты — нормативно в review, пути
закрытия и удаления — в
state-reconciliation.
Внешние границы и форматы
- qBittorrent WebUI API — единственный способ качать: источник отдаём
ему, сами по пользовательскому URL не ходим (SSRF исключён). Какие виды
источника принимаются и как разбираются —
ingest; способ добавления по
source_type— download-tracking; откуда берутся пути файлов — file-layout. - LLM — OpenAI-совместимый Chat Completions за интерфейсом (
[llm].type); контракт вызова, формат вывода и разбор ответа — recognition. - Метабазы — TMDB, TVDB, TVMaze (последняя без ключа, только сериалы).
- Jellyfin — HTTP-триггер пересканирования медиатеки, опционален; когда он дёргается и по каким переходам — file-layout.
- Telegram Bot API — приём сообщений и исходящие карточки/пинги.
- Сообщение торрент-бота — чужой текстовый формат, разбирается парсером
tgbot; наблюдения по формату — в research/torrent-bot-message.md.
Эксплуатация
-
Где работает, что рядом, кто перезапускает: домашний медиа-сервер umbar (Intel N150), docker в общей сети с qBittorrent и Jellyfin. Перезапускает docker по
restart-политике и плейбук umbar при редеплое; человек — руками, когда всё плохо. Оператор один и он же владелец. -
Внешние зависимости поимённо и чем каждая отказывает:
Зависимость Обязательна Как отказывает qBittorrent да недоступен (весь цикл встаёт, тик поллинга краснеет); отдаёт раздачу без файлов; теряет раздачу (пропажа источника); переходные состояния moving/checking*выглядят как готовностьLLM-эндпоинт да недоступен; отвечает медленно (минуты); отдаёт не-JSON или JSON не по схеме; отдаёт правдоподобную выдумку — самый неприятный случай, потому что молчаливый TMDB/TVDB/TVMaze нет недоступны; лимит запросов; пустой результат (норма для русского контента); несколько равнозначных кандидатов Jellyfin нет недоступен — скан просто не случится, состояние задачи не страдает Telegram Bot API нет недоступен — уведомление теряется, состояние задачи не страдает Диск /srv/mediaда переполнен (особенно на copy-fallback); ФС без хардлинков; файл исчез между проверкой и link(2)SQLite да database is lockedпри конкурентной записи; файл тома не смонтирован -
Кто заметит отказ и когда: владелец — по отсутствию ожидаемого пинга и по задаче, застрявшей в промежуточном состоянии; логи в stdout контейнера. Автоматического алертинга нет, метрик нет — только уведомления в Telegram о падении загрузки и о рассинхроне.
-
Характер потока: непрерывный фон (тик поллинга qBittorrent — период в database.md → «Настройки с числовым значением» — и периодическая сверка) плюс редкие события по запросу человека. Объём — единицы загрузок в день, десятки одновременно; ориентир масштаба и его аудит — задача в беклоге.
Единые точки проекта
Материал для вопроса «не появился ли второй способ делать то, что уже делается».
| Что | Где |
|---|---|
| Время | store.Now() — единственный источник меток времени в данных, всегда UTC; формат хранения — RFC 3339. Вторая санкционированная точка wall-clock — timestamp-часть ULID в ident.NewID (исключение ^internal/(ident|store)/ в .golangci.yml). Отдельно от меток в данных стоят замеры длительности: cmd/jellybit исключён из forbidigo целиком (правило ^cmd/), плюс точечные //nolint:forbidigo в internal/logging/ext.go и internal/httpapi/httpapi.go |
| Идентификаторы | internal/ident — генерация и нормализация ULID; ident.Parse на каждой входной границе |
| Целевые имена и превью раскладки | internal/naming — одна логика для превью в UI и для реального применения |
| Чистка человекочитаемых значений | три санитайзера с разным предметом, сводить их в один нельзя: recognize.SanitizeTitle — значение (недоверенный вход: LLM и метабазы), layout.sanitizeComponent — компонент пути под требования ФС, naming.sanitize — отображаемый ярлык. Значение метабазы чистится на каждой точке входа в план: сборка матча, копия кандидата для ревью, набор закреплённых значений источника и его чтение — ADR-2026-08-10-sanitize-at-every-entry |
| Разбор источника | internal/magnet и internal/torrent; инфохэш извлекается только здесь |
| Приём | use-case ingest — общий путь для HTTP, веб-UI, Telegram и CLI |
| Переходы состояний | worker под per-download блокировкой; легальность перехода задаётся декларативным графом |
| Хардлинки и удаление своих ссылок | internal/layout — единственное место, которое пишет в файловую систему библиотеки |
| Построение и проверка целевого пути | layout.BuildLinks — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью |
| Причина, по которой человек не видит плана | считается на показе (worker.ReviewData.PreviewError) и предпочитается записанной в состоянии: записанной может не быть вовсе, а после смены источника она уже про другой план — ADR-2026-08-10-reason-computed-on-read |
| Условие самообновления веб-UI | store.State.IsObservable() — «состояние ещё может измениться без человека»; транспорт своего перечня состояний не заводит, а поверхность (карточка списка, страница загрузки) держит ровно один поллер на обновляемый корень — ADR-2026-08-10-observability-is-not-terminality, правило разметки — conventions/web-ui.md |
| Трансляция доменной ошибки в код ответа | внешняя граница транспорта (httpapi, tgbot); правило — conventions/errors.md |
| Логирующий чекпоинт | доменная граница, один на операцию; правило — conventions/logging.md |
| Настройки | один TOML-файл, валидируется на старте; образец config.example.toml — источник истины по полям |
Деплой
Работает в docker в одной среде с qBittorrent и Jellyfin — см. ADR-2026-07-24-local-image-build.
Сборка: статический бинарь (GOOS=linux GOARCH=amd64 CGO_ENABLED=0) и полный
образ собираются локально на control-хосте (task image упаковывает бинарь в
distroless/static). Образ едет на сервер через docker save/load (роль
app_image в umbar), там и запускается. Go-тулчейн и docker build на сервере
не нужны.
Разделение ответственности: jellybit (этот репозиторий) даёт бинарь и
Dockerfile; umbar — оркестрацию (доставка, docker compose,
playbook-jellybit.yml, рендер секретов).
Параметры запуска:
- Общая docker-сеть (external, напр.
media-net) — адресация по именам (http://qbit:8989,http://jellyfin:8096). Веб-UI публикуется на хост (8080:8080) для LAN. qBit валидирует Host-заголовок — в umbar выставленWebUI\ServerDomains=*; LLM на хосте достаётся черезhost.docker.internal. user: "1000:1000", UMASK 022 — единый системный пользователь umbar.- mount
/srv/media— единая песочница (см. ниже). - mount конфига
/srv/applications/jellybit/config→/config(ro),config.tomlс правами0600; рендерится плейбуком umbar, бекапу не подлежит. - mount данных
/srv/applications/jellybit/data→/data, SQLite/data/jellybit.db. Бекапить обязательно — без него редеплой стирает всё in-flight состояние. - healthcheck зовёт сам бинарь (
jellybit healthcheck): в distroless нет shell и curl.
Единая песочница /srv/media
Весь медиа-стек лежит под одним каталогом и монтируется идентично
(/srv/media:/srv/media) во все медиа-приложения:
/srv/media/
incomplete/ ← qBit качает сюда
downloads/ ← готовые раздачи (источник хардлинка)
movies/ series/ ← библиотека Jellyfin (цель хардлинка)
Так как всё под одним mount'ом, работают и хардлинк (downloads →
movies/series), и мгновенный move qBit (incomplete → downloads) — границ
между точками монтирования (EXDEV) нет. Путь из qBittorrent уже равен
хост-пути, трансляция не нужна (path_map — фолбэк, обычно пуст). Секреты и
чужие приложения (/srv/applications) в песочницу не попадают. Библиотеки
Jellyfin указывают на movies/series, а не на корень — иначе в индекс попадут
downloads/incomplete.
Открытые вопросы
Места, где устройство знаемо тонкое: не дефекты, а принятые пока пробелы. Здесь только адрес и одна фраза — что именно не сделано; работа под каждым живёт задачей в tasks/BACKLOG.md. Список нужен ревью: правка, попавшая в такую область, стоит дороже, чем выглядит.
| Область | Чего нет сегодня | Задача |
|---|---|---|
| Масштаб | ориентир 100/1000 загрузок не зафиксирован, узкие места SQLite, воркера и поллинга не измерены | scale-100-downloads |
| Ретеншен | терминальные задачи и сырые ответы LLM копятся вечно, авточистки нет | db-retention-cleanup |
| Бекап | бекапить /data требуется, а стратегия и ротация не описаны |
sqlite-backup |
| Метрики и алертинг | healthcheck проверяет только сам сервис; метрик и алертинга нет, отказ виден по застрявшей задаче | deep-healthcheck-dependencies |
| Идентичность раздачи | split v1/v2-хеши не связаны, паре xt из магнета доверяем |
infohash-identity-integrity |
| Расход внешних лимитов | кэша ответов метабаз нет, повтор распознавания бьёт провайдера заново | metadata-cache |
| История переходов | хранится только текущее состояние, «как сюда попали» восстанавливается по логам | download-transition-history |
| Предел ответа LLM | лимита на размер ответа нет — единственный недоверенный канал без предела; задачей пока не заведено | — |