Compare commits

...
56 Commits
Author SHA1 Message Date
av 5992d75b57 задачи: переписана disc-image-releases под проверенное поведение Jellyfin
- BDMV раскладывается каталогом, VIDEO_TS и .iso уходят в review: DVD-каталог
  Jellyfin распознаёт, но не проигрывает
- добавлены «Затрагивает» и критерии приёмки, задача проходит tasks.py ready
2026-08-10 20:12:06 +03:00
av f89911447d задачи: заведён баг о застывшей странице загрузки
- страница /download/{id} держит бейдж «распознаётся» после перехода задачи
  дальше; воспроизведено на боевом umbar на последней версии
- причина неизвестна: тик самообновления объявлен и покрыт тестом, поэтому
  первый критерий приёмки — назвать, на каком шаге он теряется
2026-08-10 18:05:34 +03:00
av b939192348 закрыта задача bulk-delete-page, заведены три задачи из урожая ревью 2026-08-10 17:51:59 +03:00
av 288be8ec34 web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
2026-08-10 17:43:36 +03:00
av a7c1efd8eb claude code: набор плагинов переведён на av-dev-docs, av-dev-tasks и av-dev-code 2026-08-10 14:07:51 +03:00
av f6f07e520b закрыта задача card-stale-after-download-finish 2026-08-10 14:03:01 +03:00
av a5d873b62d web-ui: карточка и страница обновляются, пока задачу может двигать фон
- условие самообновления — доменный предикат store.State.IsObservable() вместо
  фазы catched; один поллер на поверхность, интервалы 5 с и 15 с
- отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели
  вместо 404/500, который htmx не свопит
- заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел
  «Живой поллинг» в конвенции веб-UI
2026-08-10 14:02:38 +03:00
av 969926fae3 tasks: заведены баг устаревшей карточки и цель группового удаления
- fix card-stale-after-download-finish: карточка списка не обновляется после выхода задачи из downloading
- goal bulk-download-management + feature bulk-delete-page: удаление раздач с файлами пачкой на отдельной странице
2026-08-10 12:32:17 +03:00
av 1479d10d4c закрыта задача long-title-to-review 2026-08-10 12:16:58 +03:00
av b9f0929d0c layout: непомещающееся целевое имя уводит задачу в review вместо failed
- предел длины компонента (255 байт) проверяется в BuildLinks до первой
  операции с ФС: ни каталога, ни ссылки при отказе не создаётся
- причина пустого предпросмотра считается на показе (ReviewData.PreviewError)
  и печатается в панели действий и в карточке Telegram: у задачи без
  записанной причины взять её больше неоткуда
2026-08-10 12:16:44 +03:00
av 1710e5a9d5 закрыта задача metadata-title-sanitize 2026-08-10 10:41:37 +03:00
av 9aecf757e0 recognize: название из метабазы санитизируется перед попаданием в план
- чистка стоит на каждой точке входа значения метабазы в план — сборка матча,
  копия кандидата для ревью, набор закреплённых значений источника и его
  чтение: гарантия, поставленная только на запись, обходится данными,
  сохранёнными прежними версиями
- название, непригодное как имя каталога (пустое или без единой буквы и
  цифры), не подставляется — раздача уходит в review с названной причиной
- гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям
  провайдера, чистится только копия, уходящая дальше
2026-08-10 10:41:16 +03:00
av bb278e8744 tasks: груминг — верх очереди отдан багам и мелочам
- в «Ядре» первыми стоят metadata-title-sanitize и long-title-to-review,
  за ними живая проверка формы ответа TheTVDB и confidence-гейт
- в «Инфраструктуре» первой стала background-error-noise: единственный
  ready-дефект секции, решение по бэкоффу принято 2026-08-06
- infohash-identity-integrity понижен до research и сдвинут вниз: тело само
  не решает между change и ограничением в документе, взять его нельзя
2026-08-10 08:57:00 +03:00
av 6001f21a60 docs: вычитка текста, написанного при подъёме канона
- «общий станок» заменён прямым называнием: гейт один на все задачи
- «отсутствие лимита» → «лимита нет», как тот же факт записан в database.md
2026-08-09 19:26:37 +03:00
av 69853a96c9 docs: разобран урожай судей после подъёма канона
- перечень команд пользователя убран из architecture.md в спеки review и
  state-reconciliation, где ему дом: обзор успел разойтись с ними в обе стороны
- README перестал дублировать деплой и статус — теперь ссылается на дом
- статус «заведена ли задача под пробел» сведён в один регистр открытых вопросов
- шаги tasks.py и openspec.py названы в перечне «что красит безусловно»
- из спеки download-tracking сняты числа умолчаний: их дом — database.md
2026-08-09 19:24:59 +03:00
av c5d62d76ee docs: канон поднят с версии 7 до 12
- каталог задач переехал в tasks/ в корне, спринт упразднён — приоритет
  теперь порядок строк в BACKLOG.md, четыре задачи набора вернулись в беклог
- гейт: путь docs.py переведён на av-dev-docs вместо снесённого av-dev-pm,
  добавлены шаги tasks.py check и openspec.py check
- относительные ссылки внутри задач и ссылки из docs/ на задачи починены
2026-08-09 19:09:53 +03:00
av 9a624d4e13 tasks: остаток урожая ревью разнесён по домам
- tests-convention: два кандидата про тесты — один стенд чужого API на пакет,
  интеграционный тест обязан утверждать
- convention-candidates: язык вывода в пяти местах без связывающего правила
- quality-review-agents: четыре прохода не сообщили потолок находок —
  первый замер калибровки
2026-08-07 17:09:52 +03:00
av 397f8aa2c3 tasks: два дефекта из ревью tvdb-title-locale взяты в спринт
- metadata-title-sanitize: название из метабазы подставляется в план мимо
  санитизации и уезжает в имя каталога дословно
- long-title-to-review: имя длиннее ~237 байт роняет раскладку в failed
  вместо отправки на ревью
2026-08-07 17:03:59 +03:00
av 1d375f55ba закрыта задача tvdb-title-locale
- вопрос о неподтверждённой форме ответа TVDB вынесен разведкой
  tvdb-search-response-live-check: закрытие задачи стёрло бы его вместе с файлом
2026-08-07 15:17:29 +03:00
av fdbc781197 metadata: TVDB отдаёт локализованное название и оригинал
- локаль из [general].language применяется при разборе ответа /search, а в
  запрос не уходит: параметр language у TVDB — фильтр выдачи, а не селектор
  перевода (ADR-2026-08-07)
- Title берётся из блока translations с тотальным фолбэком на primary name,
  OriginalTitle — из primary name; форма ответа сверена по документации и
  живым прогоном не подтверждена (docs/research)
- неожиданная форма ответа даёт WARN: признак — отсутствие во всей выдаче
  ключей языка ожидаемого вида, а не неудача разбора блока
2026-08-07 15:17:05 +03:00
av 0c83385098 tasks: заведена задача про конвенцию тестов
- tests-convention: разобрать кандидатов пункта «Тесты» и записать
  docs/conventions/tests.md, четыре критерия приёмки с оракулами
- пункт «Тесты» снят из convention-candidates и заменён ссылкой, чтобы
  не жить вторым домом
2026-08-07 13:48:01 +03:00
av a47a767691 docs: документы дополнены материалом для ревью
- review.md: заведён род узла «вызов LLM и разбор ответа», добавлены вопросы
  тем autotests, security и operations, маршрут к уже механизированному
- architecture.md: «Открытые вопросы» вместо «пока нет» — восемь областей
  знаемо тонкого устройства со ссылкой на задачу
- security.md: снят указатель на несуществующую задачу про лимит ответа LLM
2026-08-07 13:47:52 +03:00
av b879c049ea docs: документы подняты на канон 7
- review.md переведён на словарь меток: вопросы адресованы темам, триггеры
  профиля стали триггерами метки в три списка, quick/standard/wide → small/
  medium/large, профиль deep упразднён
- openspec/config.yaml переписан по канонической форме: адреса passport и
  CLAUDE.md вместо пересказа правил ревью и конвенций
- разобраны находки doc-consistency и doc-code-drift: исключение инварианта
  сверено со спеками, единая точка времени и таблица classifyErr дополнены,
  MaxTorrentSize получил дом в database.md
2026-08-07 13:01:23 +03:00
av b450ab1fd5 закрыта задача ingest-nits 2026-08-06 18:20:12 +03:00
av d081ef1d30 ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add
- имя раздачи нормализуется на границе разбора: вырожденное `-`
  (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше
  не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки
  вывода имени
- контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и
  удерживается структурно; три транспорта перестали обещать идентификатор,
  которого нет, и коррелируют отказ по request_id
- scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах
  воркера — записи об отказе qBittorrent и метабаз получили download_id
  и infohash; граница разбора bencode записана в docs/research
2026-08-06 18:20:12 +03:00
av 52615e4e49 review: триггеры профиля отвязаны от затронутого узла
- перечни узлов названы картой мест, где живут правила; ступень поднимает
  новое или изменённое правило, а не правка рядом с ним
- заведён закрытый перечень отсекающих условий: исход, названный дельта-спекой
  поимённо, отсутствие новых сценариев, правка сообщений и тестов, сужение
  существующей нормализации
- записан ориентир частоты: deep — исключение на крупной функциональности,
  задача-уборка идёт в quick или standard даже в узле из перечня
2026-08-06 17:55:29 +03:00
av 5c79fdfffe закрыта задача dismiss-marker-lost 2026-08-06 15:51:35 +03:00
av 0b02a8c224 specs: в state-reconciliation разведены Cancel и Dismiss по состояниям
- требование «Ручное закрытие загрузки» приведено к коду: два пути закрытия,
  error_code на каждом, раскладка поверхностей — описательно, а не SHALL
- уборка своего торрента после отмены названа исключением по состоянию, а не
  по команде; убрана ложная гарантия «данных пользователя не касается»
- в docs/review.md записан проскочивший дефект гарда окна после add и новый
  вопрос проходу adversary про асимметрию признака владения
2026-08-06 15:50:41 +03:00
av 01e64d60de specs: правки по дозапущенному архитектурному проходу
- термин «тик-снимок» определён при первом употреблении
- маршрут восстановления в download-tracking больше не пересказывает
  правила Retry — только ссылается на state-reconciliation
- заголовок сценария ingest не обещает исхода, которого сценарий не даёт
2026-08-06 14:51:18 +03:00
av 5a8c439899 закрыта задача catched-source-type-refresh 2026-08-06 14:45:01 +03:00
av 30ee598547 download-tracking: требование о re-read source_type приведено к коду
- re-read `source_type` перечитывается под блокировкой после тик-снимка, а
  остаточное окно вывода имени названо известным ограничением с ценой и
  достоверным маршрутом восстановления (ручной шаг + `Retry`, не самоисцеление)
- в `ingest` снята парная ложная гарантия «воркер добавит раздачу файлом»,
  добавлены сценарии на оба окна апгрейда и на недоступные байты `.torrent`
- заведён ADR о том, что при разрыве спека↔код двигается тот, чья формулировка
  сильнее рационали
2026-08-06 14:44:21 +03:00
av f42db0a275 sprint: набран спринт 2026-08-06 под цель распознавания
- в наборе шесть задач: локаль TVDB и confidence-гейт под цель, плюс баги и
  техдолг помимо неё
- взятым дописаны разделы своего типа: «Затрагивает», критерии с оракулами,
  воспроизведение
- решены развилки: оба расхождения код↔спека правятся спекой (и потому стали
  chore), опрос qBittorrent тормозится бэкоффом до минутного потолка
2026-08-06 13:57:33 +03:00
av d2d386945e tasks: выкинуты задачи про сбор базы человеком
- закрыты few-shot на правках человека и eval-харнес с размеченным корпусом:
  направление — тюнинг автоматического распознавания без участия человека
- цель распознавания переписана: точность видна по рабочему потоку, а не по
  фиксированному корпусу
- поправлены ссылки на удалённые задачи в CLAUDE.md, review.md,
  research/README.md и в задаче про confidence-гейт
2026-08-06 13:41:49 +03:00
av 66d39297c5 docs: проект переведён на канон документов версии 4
- каталог задач: PLAN.md → ROADMAP.md с каноническими секциями, все 44
  записи получили тип, заголовки приведены к форме своего типа
- расхождения, найденные судьями канона: исключения инварианта «источник
  неприкосновенен», инвариант про один активный infohash, UTC в logging.md,
  поведение из architecture.md заменено ссылками на спеки
- триггеры профиля ревью переписаны под умолчание standard
2026-08-06 13:32:06 +03:00
av 905a1ee4d3 backlog: добавлена задача про локаль названий TVDB
- [general].language доезжает только до TMDB и промпта LLM: клиент TVDB читает
  primary name и отдаёт название на языке оригинала
- в охвате также OriginalTitle, который TVDB не заполняет вовсе
2026-08-06 12:49:45 +03:00
av a4f5faae47 docs: ADR о переезде конвейера ревью в плагины
- Заведена запись ADR-2026-08-04: пайплайн и ревью переехали в
  av-dev-pipeline и av-dev-pm, причина — расхождение копий между
  jellybit и healthlog и переиспользуемость пайплайна.
- Упразднение прохода idiom записано отдельным последствием со ссылкой
  на «Перестали проверять сознательно» в docs/review.md.
2026-08-04 09:32:52 +03:00
av 42d5b73a04 docs: перевод документации на канон av-dev
- Раскладка docs/ приведена к канону 2: заведены passport/architecture/
  database/security/review и research; docs/specs, drafts, backlog, review/
  и BRIEF.md разобраны и удалены, беклог переехал в docs/tasks (34 задачи,
  6 целей, слаги на английский).
- Нарративы specs удалены как дубли openspec-спек после поимённой сверки;
  остаток заведён задачами (редактор маппинга ревью, крайние случаи
  именования), отказ от сущности title промоутнут в ADR.
- Проектные копии агентов и скиллов ревью/пайплайна удалены в пользу
  плагинов av-dev-pm и av-dev-pipeline; в task gate добавлен шаг canon
  вместо er-schema.
2026-08-04 09:27:26 +03:00
av 08bef2cac0 deploy: переход на локальную сборку образа и доставку docker save/load
- task image принимает BUILD_ID и тегает <app>:$BUILD_ID (контракт роли app_image в umbar)
- добавлен ADR-2026-07-24-local-image-build, старый docker-deploy помечен superseded
- README/architecture/roadmap/Dockerfile обновлены под новую схему (без сборки на сервере)
2026-07-24 18:33:51 +03:00
av a2d8140e22 recognition: глобальный переключатель языка вывода [general].language
- новое поле [general].language (ru|en, дефолт en) — единый источник языка
  локализованного title детектора и локали запросов к метабазам; original_title
  всегда на языке оригинала
- локаль TMDB (поиск + credits) выводится из него, [metadata.tmdb].language убран
- промпт LLM явно задаёт язык title с fallback на оригинал
2026-07-24 14:09:04 +03:00
av 8309ce0664 backlog: хуки индекса по правилу «состояние-остаток-боль»
- переписал 21 хук: обрезанные первые абзацы и пустые «ИДЕЯ» → боль/остаток
- убрал дублирующее «ИДЕЯ (…)» в началах тел 5 идей (тип кодирует префикс [idea])
2026-07-24 09:44:38 +03:00
av 9711fe8542 settings: подключён плагин av-dev-git в project scope 2026-07-24 09:32:57 +03:00
avandClaude Opus 4.8 b4c90daae0 backlog: маркетплейс av-dev-skills по https
CLI plugin marketplace add не принимает ssh://…:2222, git-сервер доступен по
https — переводим источник маркетплейса на https://git.vakhrushev.me/av/dev-skills.git
(source-тип "git"). Плагин подключён командой в project scope; лишнее local-scope
подключение убрано.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 09:15:36 +03:00
avandClaude Opus 4.8 c28745f369 backlog: подключить скилл как плагин av-dev-backlog
Скилл беклога переехал из глобального ~/.claude/skills в плагин av-dev-backlog
(маркетплейс av-dev-skills). Подключаем его на уровне проекта через
.claude/settings.json (extraKnownMarketplaces + enabledPlugins по git-URL),
чтобы был активен у всех, кто открывает репозиторий.

Правки в доках под новую раскладку:
- task-pipeline: убран зашитый путь к backlog.py (его больше нет) — проверка
  индекса идёт командой check самого скилла;
- CLAUDE.md: зафиксировано, что скилл backlog поставляется плагином и как
  вызывается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 08:59:00 +03:00
avandClaude Opus 4.8 91db7b8393 backlog: тип задачи — английские ключевые слова idea/epic/task
Тип стал токеном команд скилла (`--type idea|epic|task`, префикс
`[idea]`/`[epic]` в заголовке), поэтому по-честному он английский, как и
прочие идентификаторы. Мигрированы 5 спекулятивных задач с `[идея]` на
`[idea]` (файлы + строки индекса + преамбула). Текст задач остаётся
русским.

Заодно две правки заголовков индекса под согласованность с `backlog.py
check` (catched-source-type, dobavlenie-edinoe-okno).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:26:28 +03:00
avandClaude Opus 4.8 2ec952c810 backlog: интеграция скилла ведения беклога
Беклог теперь ведётся пользовательским скиллом `backlog` (заведение из
диалога, разбор находок ревью, груминг, приоритизация, декомпозиция,
штурм). CLAUDE.md делегирует ему формат и держит только проектные
тонкости; добавлены источники задач (диалог, Tududi, находки ревью) и
кладбище `docs/backlog/CLOSED.md` для выкинутого без реализации.

- task-pipeline: шаги 1 и 9 больше не описывают формат сами, ссылаются на
  скилл; шаг 9 гоняет `backlog.py check` после удаления файла задачи.
- review-pipeline: отложенная реальная находка (не для текущего мерджа)
  заводится задачей через скилл с тегом партии review-ГГГГ-ММ-ДД.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:26:11 +03:00
avandClaude Opus 4.8 776a1ca6b6 ревью: скрипты гейта на python3, шаги выбираются по изменённым файлам
gate.sh и review-context.sh переписаны на python3 — в scripts/ уже жил
diff-coverage.py, а разбор вывода git и сборка сводки на shell читались хуже,
чем работали.

Гейт больше не гоняет go-шаги впустую: build, vet, lint, gofmt, тесты, -race,
покрытие и govulncheck запускаются, только если в диффе есть .go либо
go.mod/go.sum; миграции — если тронуты миграции или код. Правка документации
проходит гейт за секунды вместо минуты. Пропуск при этом не молчит: он в сводке
с причиной и уезжает в границы покрытия, а charter гейта различает «код не
трогали» (корректно) и «инструмента нет» (настоящая дыра).

Изменённые файлы считаем как объединение диффа с базой, рабочего дерева и новых
файлов: гейт гоняют и до коммита, и после, а лишний прогон шага дешевле
пропущенного.

Заодно govulncheck перестал рапортовать «уязвимостей: 0» когда он просто не смог
отработать из-за несобирающегося кода — это SKIP, а не WARN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 19:50:40 +03:00
avandClaude Opus 4.8 7473cbd6d3 ревью: включить review-code стадией 1 и убрать три избыточности
По итогам разбора собственной работы.

jellybit-review-code не запускался нигде: charter обещал «проход профиля quick»,
а quick состоял из стадий 0, 1, 5. Проход, который нельзя запустить, нельзя и
откалибровать. Теперь он стадия 1 рядом с review-specs — оба applicative, у
обоих критерий записан, различаются источники (дельта-спека и конвенции).

review-context.sh больше не выгружает go doc -short по всему модулю: это было
264 строки из 458 при том, что граф зависимостей — единственное, чего агент не
восстановит сам, — занимает 21. Публичную поверхность он вытянет go doc по
нужному месту.

Из calibration.md убрана секция дополнительных метрик: precision, корреляция и
стоимость прогона вручную никем не считаются, а набор показателей, который не
собирают, изображает измеряемость вместо того, чтобы её давать.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 19:41:57 +03:00
avandClaude Opus 4.8 52b9599aa7 go.mod: тулчейн go1.26.5 — закрывает GO-2026-5856 в crypto/tls
govulncheck нашёл уязвимость приватности Encrypted Client Hello, достижимую из
кода четырьмя трассами (слушающий сервер, клиенты qBittorrent и Jellyfin,
отправка в Telegram). Исправлено в go1.26.5.

Директива toolchain: GOTOOLCHAIN=auto скачивает нужную версию сам, системный Go
не трогаем, а бинарь для umbar собирается уже исправленным. После обновления
govulncheck чист.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:28:51 +03:00
avandClaude Opus 4.8 f21f2f9ca8 ревью: govulncheck в task setup и статус WARN в гейте
Инструмент ставится вместе с остальными (версия закреплена), гейт его больше не
пропускает.

Уязвимость почти всегда унаследована — это состояние зависимостей и тулчейна, а
не диффа, — поэтому шаг не краснит гейт, а получает статус WARN: виден в сводке,
уезжает в находки и в границы покрытия. Иначе красный гейт на каждом прогоне
перестают читать. Уязвимость, приехавшую с новой зависимостью change, агент
отличает по трассам вызовов и выводит как major.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:25:47 +03:00
avandClaude Opus 4.8 89b3285b5a конвенции: убрать из CLAUDE.md пересказ механизированных правил
Одно и то же жило в трёх местах: docs/conventions, openspec/config.yaml и
CLAUDE.md, который читается каждую сессию. Механизируемое теперь одной строкой
со ссылкой на гейт, прозой — только то, что правилом не выражается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:19:25 +03:00
avandClaude Opus 4.8 534572dc9c доки: ADR о переработке конвейера ревью и обновление беклога
ADR фиксирует «почему»: recall чек-листа равен его длине, ценность верификатора
определяется оракулом и декорреляцией с автором (а не числом ролей), отчёт без
границ покрытия хуже отсутствия отчёта.

В беклоге закрыт открытый вопрос «дробить ли review-code на узкие оптики» — не
дробим; осталась калибровка проходов и ревьювер наименований.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:18:23 +03:00
avandClaude Opus 4.8 f8edfc1782 ревью: подключить конвейер в task-pipeline и task-batch
Шаг 4 стал профилем design на предложении (архитектурная находка на готовом коде
стоит переписывания и потому игнорируется — на предложении она стоит абзаца),
шаг 7 — вызовом review-pipeline с профилем по факту изменения. Границы покрытия
протаскиваются в финальный доклад строкой.

В task-batch финальная сверка сужена до того, что появилось от слияния:
повторять полный конвейер на интегрированном диффе бессмысленно — те же проходы
на тех же файлах дают те же находки и удорожают триаж.

Заодно убрана ссылка на несуществующий скилл verify: шага не было ни в проекте,
ни у пользователя, поведенческую верификацию делает Skill run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:18:16 +03:00
avandClaude Opus 4.8 f4bd473521 ревью: переработать набор субагентов — гейт, generative-проходы, триаж
Новые: gate (запускает инструменты и интерпретирует вывод, находит отсутствующую
верификацию), rubric (порождает рубрику ДО чтения кода), reimpl (пишет свою
реализацию, не открывая существующую, диффит по решениям), idiom (заземляет
идиоматичность на stdlib и поимённые положения гайдов), negative (чего нет и что
лишнее), architecture (вход шире диффа, потолок 3), adversary (находка =
построенный путь), ops (условный постмортем), triage (единственный агрегатор).

specs получил направление code → spec — поведение, которого дельта не
заказывала, — и право сомневаться в самом требовании.

code сжат до конвенций, не выраженных правилом: механизируемое проверяет гейт,
архитектуру и стиль забрали профильные проходы. Не удалён — существующий проход
не удаляется без замера.

У каждого агента записаны вход (в том числе что читать запрещено), единый
контракт вывода, блок границ покрытия и «чего этот проход принципиально не может
поймать».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:18:05 +03:00
avandClaude Opus 4.8 2fb533e0e6 ревью: скилл review-pipeline — стадии, профили, контракт находок, храповик
Конвейер собран по типу проходов, а не по ролям: гейт (детерминированный,
блокирующий) → сверка с дельта-спеками в обе стороны → generative-проходы →
архитектура → враждебные постановки → обязательный триаж. Профили quick /
standard / deep / design с правилом выбора по факту изменения.

Контракт находок: заголовок через последствие, обязательное поле «Последствие»,
critical без оракула или построенного пути не существует, потолок 7 пунктов и
разметка «инлайн | развилка» — отчёт читает оркестратор и молча реализует
прочитанное, поэтому потолок защищает код от незаказанных правок.

Храповик находка → конвенция → правило → удаление из прозы и промптов; журнал
проскочивших дефектов и калибровка инъекцией с вердиктами keep/retune/drop.
Секция границ покрытия обязательна: отчёт без неё потребляет ощущение
проверенности, ничего не гарантируя.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:17:52 +03:00
avandClaude Opus 4.8 612344bab3 конвенции: перенести механизируемое в golangci-lint и internal/archrules
Правило, которое проверяет машина, не должно оставаться прозой: файл конвенций
на сотни строк размазывает внимание по тривиальному — модель добросовестно
проверит именование полей лога и не дойдёт до формы решения.

Включены sloglint (константный msg, стиль ключ-значение), forbidigo (fmt.Print*,
os.Getenv, time.Now мимо store.Now), errorlint (сравнение ошибок), depguard
(сторонние пакеты ошибок). internal/archrules — сканеры на то, что линтером не
выражается: направление зависимостей ядро↔транспорты, AUTOINCREMENT и серверное
время в новых миграциях, матчинг ошибки по тексту.

Код приведён к правилам: logging.StartCall как единая точка отсчёта длительности
внешних вызовов, store.Now вместо time.Now в httpapi и часах воркера,
slog.DiscardHandler в тестах.

Перенесённое вычеркнуто из docs/conventions/* и openspec/config.yaml — прозой
осталось только то, что правилом не выражается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:17:40 +03:00
avandClaude Opus 4.8 6792f7082a ревью: детерминированный гейт task gate и карта проекта для архитектурного прохода
scripts/gate.sh гонит всё, у чего есть объективный оракул (build, vet, lint,
gofmt, тесты, повтор на флаки, -race, покрытие изменённых строк, миграции,
ER-схема по диффу, gitleaks, govulncheck), не останавливаясь на первом отказе:
ревью нужна полная картина. Пропущенный шаг попадает в сводку — молча
пропущенная проверка даёт ложное ощущение проверенности.

scripts/diff-coverage.py считает покрытие именно изменённых строк: общий
процент по пакету для ревью бесполезен.

scripts/review-context.sh собирает вход, которого нет в диффе — пакеты с
назначением, граф внутренних зависимостей, публичную поверхность и инвентарь
концепций. Агент, видящий только дифф, не знает словаря проекта и потому не
может судить об архитектуре.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:17:28 +03:00
286 changed files with 18234 additions and 4127 deletions
-71
View File
@@ -1,71 +0,0 @@
---
name: jellybit-review-code
description: Ревьювер кода для jellybit (Go) — оптика архитектуры, инвариантов безопасности данных, конвенций (ошибки, логирование, конфиг, время/UTC, ULID, миграции, htmx), стиля и дублирования. Запускается как чекпоинт перед archive/коммитом: на нетривиальной задаче — в паре с jellybit-review-specs, на тривиальной — один (тогда в задании его просят бегло сверить и соответствие спекам). Работает только на чтение, код не меняет.
tools: Read, Grep, Glob, Bash
color: yellow
---
Ты — ревьювер кода проекта **jellybit** (Go, один статический бинарь
`CGO_ENABLED=0`; связующий сервис qBittorrent ↔ Jellyfin, SQLite через
`modernc.org/sqlite`). Твоя оптика — **архитектура, инварианты, конвенции, стиль
и дублирование**. Находки пиши по-русски, идентификаторы и пути — в оригинале.
Читай реальный код перед выводом, ничего не выдумывай.
## Контекст, который надо прочитать
`CLAUDE.md` (принципы, инварианты, конвенции кода), `docs/specs/architecture.md`,
относящиеся файлы `docs/conventions/*` (errors, logging, config, database,
web-ui), диф разбираемого change (`git diff` / `git status` /
`git log --oneline`).
## Что проверяешь
- **Архитектурные границы.** Единое ядро / тонкие транспорты: вся логика приёма
в use-case `Ingest`; HTTP API, веб-UI и Telegram — лишь обёртки, без бизнес-
логики в транспортах. Размещение по пакетам `internal/<компонент>` согласно
architecture.md. Минимум компонентов, без лишних сущностей.
- **Инварианты безопасности данных.** Источник неприкосновенен: только `mkdir` /
`link(2)` / `unlink` своих ссылок, никогда не трогаем файлы под
`paths.downloads`. Целевой путь санитизируется и строго под
`paths.movies`/`series` (защита от traversal), существующее не
перезаписываем. Выход LLM недоверенный — безопасность на валидации пути.
Секреты (пароли qBittorrent, API-ключи LLM/метабаз, auth-заголовки) не попадают
в логи.
- **Ошибки.** Stdlib, обёртка с контекстом (`fmt.Errorf("...: %w", err)`),
проверка через `errors.Is`/`errors.As`, трансляция на внешней границе.
- **Логирование.** Только `slog`, без `fmt.Println`; корректные уровни,
обязательные поля, ничего секретного.
- **Конфиг.** Только TOML, секреты из файла (не env), валидация на старте.
- **Время.** UTC, RFC 3339 с суффиксом `Z`, генерирует только приложение
(`store.Now()`); таймзона отображения — конфиг `[general].timezone`.
- **Идентификаторы.** TEXT ULID (lowercase) через `internal/ident`, без числовых
AUTOINCREMENT; внешние id валидируются `ident.Parse` на границе.
- **Миграции.** goose в `internal/store/migrations`; при изменении структуры
(таблица/столбец/индекс/связь) в том же change обновлена ER-схема
`docs/specs/database.md`.
- **Веб-UI (htmx).** Единый партиал = страница = фрагмент, ветвление по `isHTMX`,
деградация без JS, ошибка на htmx-пути = 200 + фрагмент, самозавершающийся
поллинг.
- **Стиль и дублирование.** Код читается как окружающий (нейминг, плотность
комментариев, идиомы). Ищи копипасту и упущенные возможности переиспользования,
но без золочения — правки должны быть right-size под задачу.
Если в задании просят (тривиальная задача, ты единственный ревьювер) — добавь
**беглую** сверку с дельта-спеками и tasks.md change: реализовано ли заявленное,
нет ли забытых задач. Глубокую спек-проверку на нетривиальных делает
`jellybit-review-specs`.
## Формат вывода
Находки по критичности, каждая — с файлом/строкой и кратким «почему»:
- **Блокеры** — нарушенные инварианты, сломанная архитектура, утечка секретов,
баги обработки ошибок/данных.
- **Важное** — отступления от конвенций, дублирование, слабые места.
- **Мелочь-инлайн** — то, что оркестратор поправит сам.
- **Развилки-для-автора** — где нужно решение человека (крупная переработка,
компромисс). Формулируй как вопрос с вариантами.
## Ограничения
Только чтение и анализ. Не редактируй код, не запускай сборку/тесты с
сайд-эффектами, не коммить. Результат — текст находок для оркестратора.
-66
View File
@@ -1,66 +0,0 @@
---
name: jellybit-review-specs
description: Ревьювер спек и требований для jellybit (Spec Driven Development на OpenSpec). Оптика — соответствие реализации/дизайна дельта-спекам и tasks: покрытие Requirements и сценариев GIVEN/WHEN/THEN, целостность и непротиворечивость дизайна, границы scope, отражение инвариантов безопасности данных в спеке. Используется на двух чекпоинтах ревью-процесса: ревью дизайна/спек ДО кода и сверка кода со спеками ПОСЛЕ apply. Работает только на чтение, код не меняет.
tools: Read, Grep, Glob, Bash
color: cyan
---
Ты — ревьювер спецификаций проекта **jellybit** (Go, один статический бинарь;
связующий сервис qBittorrent ↔ Jellyfin). Разработка идёт по Spec Driven
Development через OpenSpec: сперва спека — потом код. Твоя оптика — **спеки и
требования**, а не стиль кода. Находки пиши по-русски, идентификаторы, пути и
ключевые слова спек (`SHALL`, `GIVEN/WHEN/THEN`) — в оригинале. Читай реальные
файлы перед выводом, ничего не выдумывай.
## Контекст, который надо прочитать
Всегда сперва подними: `CLAUDE.md` (раздел «Инварианты» и «Spec Driven
Development»), `openspec/changes/<id>/` разбираемого change (proposal.md,
design.md, дельта-спеки с `ADDED/MODIFIED/REMOVED Requirements`, tasks.md),
затронутые `openspec/specs/*/spec.md`, `docs/specs/architecture.md`. Если тема
ещё живёт в `docs/specs/` (не перенесена в OpenSpec) — источник истины там.
## Два режима (что ревьюишь — скажут в задании)
1. **Дизайн/спеки ДО кода.** Проверяешь сам change как артефакт: полнота
покрытия постановки; сценарии `GIVEN/WHEN/THEN` без дыр, противоречий и
недостижимых веток; scope не раздут и не урезан молча; каждый
`### Requirement` содержит литерал `SHALL` или `MUST`; структурные заголовки
английские; согласованность с текущими спеками и capability-нарезкой; в спеке
отражены задетые инварианты безопасности данных (источник неприкосновенен,
санитизация целевого пути и защита от traversal, недоверенный выход LLM,
секреты не в логах). Отметь, если `openspec validate --strict <id>` очевидно
упадёт.
2. **Код против спек ПОСЛЕ apply.** Сверяешь реализацию с дельта-спеками и
tasks.md: все ли Requirements и сценарии реально реализованы; нет ли
отклонений от согласованного дизайна; покрыты ли ключевые сценарии тестами;
не осталось ли незакрытых или потерянных задач в tasks.md. Диф бери через
`git diff` / `git status` / `git log --oneline`.
## Метод
1. Выпиши нумерованный чек-лист Requirements и сценариев из дельта-спек.
2. Сопоставь каждый пункт с дизайном (режим 1) или с кодом/тестами (режим 2);
помечай: Покрыто / Частично / Не покрыто / Неоднозначно.
3. Для каждого конкретного утверждения открой реальный источник и подтверди —
не заявляй поведение, которого не прочитал.
4. Отдельно проверь инварианты безопасности данных: где спека/код трогают
раскладку файлов, пути, источник (`paths.downloads`) — убедись, что заявлены
и соблюдены гарантии (только свои ссылки, строго под `paths.movies`/`series`,
существующее не перезаписываем).
## Формат вывода
Верни находки, сгруппированные по критичности:
- **Блокеры** — дыры покрытия, нарушенные инварианты, противоречия, невыполнимая
спека. Каждый — с указанием файла/пункта и кратким «почему».
- **Важное** — неоднозначности, слабое тестовое покрытие сценария, риск scope.
- **Мелочь-инлайн** — то, что оркестратор поправит сам без обсуждения.
- **Развилки-для-автора** — где нужно решение человека (компромисс, смена scope,
трактовка требования). Формулируй как вопрос с вариантами.
## Ограничения
Только чтение и анализ. Не редактируй код и спеки, не запускай ничего с
сайд-эффектами, не архивируй change. Твой результат — текст находок для
оркестратора, а не правки.
+13 -1
View File
@@ -1,5 +1,17 @@
{ {
"enabledPlugins": { "enabledPlugins": {
"frontend-design@claude-plugins-official": true "frontend-design@claude-plugins-official": true,
"av-dev-git@av-dev-skills": true,
"av-dev-docs@av-dev-skills": true,
"av-dev-tasks@av-dev-skills": true,
"av-dev-code@av-dev-skills": true
},
"extraKnownMarketplaces": {
"av-dev-skills": {
"source": {
"source": "git",
"url": "https://git.vakhrushev.me/av/dev-skills.git"
}
}
} }
} }
@@ -1,159 +0,0 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -1,117 +0,0 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
-289
View File
@@ -1,289 +0,0 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
-113
View File
@@ -1,113 +0,0 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
-147
View File
@@ -1,147 +0,0 @@
---
name: openspec-sync-specs
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.5.0"
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
For each repo-local capability delta spec path returned by the CLI:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
5. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
-211
View File
@@ -1,211 +0,0 @@
---
name: task-batch
description: Автономно проводит несколько задач jellybit из беклога разом — планирует порядок и зависимости, гонит каждую задачу отдельным сабагентом в своём git worktree через task-pipeline, интегрирует в master по одной ветке через rebase/ff (линейная история), в конце прогоняет все тесты и сверяет код с требованиями по каждой затронутой capability. Использовать, когда пользователь просит взять/сделать несколько задач из беклога сразу.
---
# Батч задач (jellybit)
Оркестратор **набора** задач по Spec Driven Development. Планирует порядок,
раскидывает задачи по изолированным worktree, каждую проводит через полный цикл
`task-pipeline`, затем сводит в master линейной историей и делает финальную
сверку. Тонкая обёртка над `task-pipeline` — не переизобретай её шаги, вызывай
как есть.
Работай **максимально автономно**. Зови пользователя (через **AskUserQuestion**)
только на реальных развилках — как в `task-pipeline`. Механику — планирование,
worktree, rebase, интеграцию, чистку — делаем без спроса.
Перед стартом прочитай `CLAUDE.md`, `README.md`, `BRIEF.md`,
`docs/specs/architecture.md`, если ещё не в контексте.
## Ключевое отличие от одиночного пайплайна
`task-pipeline` коммитит **прямо в master** (память `commit-directly-to-master`).
Здесь это невозможно для параллельных задач, поэтому батч — **осознанное
исключение**: заводим временные ветки/worktree лишь как средство изоляции, а
конечное состояние — та же линейная trunk-based история master через rebase +
fast-forward. Ветки после вливания удаляем. Дух памяти (линейный master без
мусорных мёрджей) сохраняется.
## Модель исполнения
- Каждая задача = **один автономный сабагент** (`general-purpose`, чтобы иметь
доступ к Skill и Agent для вложенных ревью-чекпоинтов), работающий **только в
своём worktree** и прогоняющий `task-pipeline` целиком на этой задаче.
- Оркестратор (главный агент) не пишет код задач сам — он планирует, заводит
worktree, запускает сабагентов, интегрирует ветки в master и делает финальную
сверку.
- Стиль правок внутри — заточка под проект и конвенции, right-size, без золочения
(память `convention-design-approach`).
## Шаги
### 1. Выбрать набор задач
- Если набор задан (список slug'ов/файлов, «топ-3 высоких», «эти три») — используй.
- Иначе покажи кандидатов из `docs/backlog/README.md` (высокий приоритет, не
`[идея]`) через **AskUserQuestion** (multiSelect) и дай выбрать.
- `[идея]`-задачи включаются, но помни: сабагент проведёт их сперва через
`opsx:explore` (см. `task-pipeline`) — это тяжелее и может упереться в развилку.
Прочитай файл каждой выбранной задачи и связанные спеки/ADR/черновики.
### 2. Спланировать порядок, зависимости и конфликты (автономно)
Для каждой задачи определи по её файлу и `capability-map` (память):
- **Затронутые capability** (из 11: identity, ingest, download-tracking,
recognition, metadata-match, review, file-layout, state-reconciliation,
notifications, web-ui, live-status).
- **Жёсткие зависимости**: задача B строится на результате A → A строго раньше B.
- **Миграции БД — пред-назначение номеров** (не сериализация). Определи, какие
задачи, вероятно, добавят миграцию (новая таблица/столбец/индекс/связь), и
**заранее раздай им номера**: посмотри последний номер в
`internal/store/migrations/` и назначь `0012`, `0013`, … по одной на задачу.
Номер уходит в charter сабагента (шаг 4). Так migration-задачи можно гнать
параллельно — файлы миграций не столкнутся, а `docs/specs/database.md`
(ER-схема) правят разные строки, textual-конфликт при rebase мелкий и решается
на интеграции.
- **Жёстко сериализуем** (не гоняем одновременно) только настоящие пересечения:
- **Одна capability на несколько задач**: две задачи, правящие одну capability
(тем более один и тот же `### Requirement` в её спеке), дают не текстовый, а
**семантический** конфликт при `archive` — сериализуем по смыслу, а не только
по файлам.
- Пересечение по одним и тем же исходникам.
- **Мягкие конфликты** (обычно авто-мёрджатся при rebase, сериализовать не надо):
`docs/backlog/README.md` (каждая задача убирает свою строку) и
`openspec/specs/<cap>` разных capability (archive вливает дельты) — разные
строки/файлы.
Собери план: **волны** параллельно-безопасных задач + сериализованный хвост
конфликтоопасных, с учётом зависимостей. Покажи план короткой репликой и иди
дальше. **AskUserQuestion — только** если порядок реально неоднозначен или
задачи глубоко связаны продуктово.
### 3. Свежий master как база
Убедись, что рабочее дерево чистое и master свежий (`git status`, при наличии
remote — `git fetch` и синк). Зафиксируй базовый коммит. **Новые ветки бери от
свежего master**; ветки следующей волны — от master, уже включающего результат
предыдущих волн.
### 4. Прогнать волны
**Потолок параллелизма — 2–3 задачи одновременно.** Каждая задача тянет полный
`task-pipeline` + вложенные ревью + `task test`/`go build`, поэтому больше трёх
разом душат домашнюю машину и провоцируют гонки. Волну шире трёх бей на под-пачки
по ≤3 и гони их последовательно.
Для каждой задачи в под-пачке:
1. Заведи worktree + ветку от текущего вершинного master:
`git worktree add <path> -b task/<slug> master`. Путь — рядом с репо или в
`./tmp/` (память `use-project-tmp-dir`; НЕ в системном `/tmp`). Имя ветки —
`task/<slug>`.
2. Запусти **по одному сабагенту на задачу, все в одном сообщении** (конкурентно,
но не больше трёх), `subagent_type: general-purpose`. Charter сабагента:
- Работай **строго в своём worktree** `<path>`; в другие каталоги и в master
не лезь.
- Прогони skill **`task-pipeline`** ровно на этой задаче (`<slug>`/файл),
полный цикл SDD с промежуточными ревью-чекпоинтами.
- Если задаче на шаге 2 назначен **номер миграции** — используй строго его
(`internal/store/migrations/<номер>_*`), не бери «следующий свободный» сам.
- **Ревью-чекпоинты**: попробуй запустить агентов `jellybit-review-specs` /
`jellybit-review-code` через Agent tool (как в `task-pipeline`). Если
вложенный запуск сабагента недоступен — проведи ревью **инлайн**, используя
charter'ы `.claude/agents/jellybit-review-*.md` как чеклист. Чекпоинт «ревью
спек ДО кода» не пропускай.
- **Коммит.** `task-pipeline` коммитит в текущую ветку — а это твоя
`task/<slug>` в worktree, так что специально ничего переопределять не нужно.
Всё остальное (`opsx:archive`, чистка беклога `docs/backlog/<slug>.md` +
строка индекса, синк спек/ADR) ложится коммитами туда же. Master не трогай,
ветку не переключай, ничего не пушь, новых worktree не создавай.
- `task test` / `task lint` в своём worktree — добейся зелёного.
- Верни отчёт: что сделано, какие развилки решались, изменённые файлы,
**добавлял ли миграцию и её номер**, затронутые capability, статус
тестов/линта, все неразрешённые вопросы.
Если сабагент упирается в развилку, которую `task-pipeline` выносит на
пользователя, — он останавливает свою задачу и возвращает вопрос; оркестратор
собирает такие вопросы и выносит их пользователю (**AskUserQuestion**), остальные
задачи при этом продолжаются.
### 5. Интегрировать в master — rebase + fast-forward, по одной ветке
Сводим ветки в master **строго последовательно** (линейная история), в порядке
зависимостей — по одной ветке за раз. Вливаем **только зелёные** ветки:
провалившиеся/зависшие задачи в интеграцию не берём (см. политику ниже).
Для каждой готовой (зелёной) ветки `task/<slug>`:
- `git rebase master task/<slug>` — перенос ветки на текущую вершину master.
- Резолв конфликтов (их почти нет — конфликтоопасное сериализовано, номера
миграций розданы заранее). Если rebase дал неавтоматический конфликт — **не
форсируй**: прерви (`git rebase --abort`), оставь ветку/worktree как есть и
вынеси развилку пользователю (это признак нераспознанного пересечения).
- `git checkout master && git merge --ff-only task/<slug>`.
- После каждой интеграции: `task test` (+ `task lint`) на master. **Красное —
откати эту интеграцию** (`git reset --hard` на прошлую вершину master), ветку с
worktree сохрани, вынеси пользователю. Master **никогда** не остаётся
полузелёным.
- Только после зелёного: `git worktree remove <path>` и `git branch -d task/<slug>`.
Так каждая следующая ветка ребейзится на уже обновлённый master — история
линейна, каждая задача = свой осмысленный коммит (или несколько по фазам apply).
**Политика частичного провала.** Если задача упала (сабагент вернул
неразрешённую развилку, тесты в её worktree красные, rebase/merge конфликтует) —
она **не блокирует остальные**: интегрируем все зелёные, упавшую оставляем в её
worktree и ветке нетронутой (ничего не удаляем), и в финальном докладе (шаг 8)
перечисляем провалившиеся с их отчётами и причиной. Пользователь потом решит:
дожать вручную, переназначить, отложить.
### 6. Финальный гейт — все тесты
На master после всех интеграций: `task test` + `task lint` (+ `task build`).
Зелёное — обязательно.
### 7. Финальная сверка кода с требованиями — по затронутым capability
Собери **объединение затронутых capability** по всем задачам. Запусти **по одному
сабагенту-ревьюверу на каждую затронутую capability, все в одном сообщении**
(параллельно), `subagent_type: jellybit-review-specs`. Каждому дай:
- имя capability и путь `openspec/specs/<cap>/spec.md`;
- интегрированный diff `git diff <база>..HEAD`, сфокусированный на файлах этой
capability;
- задание: сверить **код на master с требованиями** capability — покрытие
`### Requirement` (все содержат `SHALL`/`MUST`), сценарии `GIVEN/WHEN/THEN`,
инварианты безопасности данных, непротиворечивость код↔спека после слияния
нескольких задач (косвенные рассинхроны на стыках).
Опционально, если задач много и они пересекаются, добавь один
`jellybit-review-code` на весь интегрированный diff (архитектура/конвенции/стиль
сквозняком). Замечания отрабатывай как в `task-pipeline`: мелочь чини инлайн,
развилки — на пользователя; после правок — снова `task test`/`task lint`.
### 8. Прибраться и доложить
- Убери worktree/ветки **только успешно влитых** задач (`git worktree remove` +
`git branch -d` уже сделаны на шаге 5); в конце `git worktree prune`.
Worktree/ветки **провалившихся** задач **не трогай** — они нужны пользователю
для ручного дожатия.
- Доложи кратко: какие задачи сделаны, план волн и порядок интеграции, какие
развилки решались, коммиты по задачам, итог финальной сверки, ссылки на
архивные change. **Отдельно перечисли провалившиеся** задачи с причиной, их
отчётом и путём к оставленному worktree/ветке.
## Тонкости
- **Номера миграций раздаёт оркестратор** (шаг 2), сабагент берёт назначенный, а
не «следующий свободный» — тогда migration-задачи безопасны параллельно.
- **Изоляция параллельных тестов.** Прежде чем гнать несколько `task test` разом,
убедись, что тесты не делят фиксированный TCP-порт или файл БД (обычно берут
`t.TempDir()`/эфемерный порт — тогда ок). Если делят — гони такие тесты
последовательно, а не в параллельной под-пачке.
- Ревью выполненного — **до** чистки беклога; это забота `task-pipeline` внутри
каждого сабагента (память `review-before-backlog-cleanup`). Оркестратор
дублировать не должен.
- Не пропускай `openspec validate --strict` — это тоже внутри `task-pipeline`.
- Если сабагент вернул крупную переработку/смену подхода — это развилка, не
вливай молча, вынеси пользователю.
- Держи пользователя в цикле короткими репликами на переходах фаз (план → волны →
интеграция → финальная сверка), но не проси подтверждать механику.
-156
View File
@@ -1,156 +0,0 @@
---
name: task-pipeline
description: Автономно проводит задачу jellybit через полный цикл SDD — от выбора в беклоге до коммита (opsx explore→propose→ревью спек→apply→ревью кода→archive→чистка беклога). Использовать, когда пользователь просит взять/сделать задачу из беклога или довести идею до реализации.
---
# Пайплайн задачи (jellybit)
Оркестратор одной задачи по Spec Driven Development: проводит её от беклога до
коммита максимально автономно, привлекая пользователя **только на реальных
развилках** (компромиссы, изменение scope, угроза инвариантам). Механику не
согласовываем — делаем.
Перед стартом прочитай `CLAUDE.md`, а также `README.md`, `BRIEF.md`,
`docs/specs/architecture.md`, если ещё не в контексте. Это тонкая обёртка над
каноническими скиллами `opsx:explore` / `opsx:propose` / `opsx:apply` /
`opsx:archive` — вызывай их через Skill, не переизобретай их шаги.
## Принцип автономности
Зови пользователя (через **AskUserQuestion**) только когда решение реально его:
- **Выбор задачи**, если он не задан явно.
- **Развилки грумминга** на explore: несколько равнозначных направлений,
спорный scope, продуктовый компромисс.
- **Замечания ревью спек**, требующие выбора: смена подхода, урезание/расширение
scope, риск инварианту безопасности данных.
- Всё остальное — механика: делаем без спроса. Мелкие замечания ревью чиним
инлайн, не логируем (память `review-before-backlog-cleanup`).
Стиль правок — заточка под проект и конвенции, right-size, без золочения
(память `convention-design-approach`).
## Шаги
### 1. Выбрать / прочитать задачу
- Если задача задана (slug, файл в `docs/backlog/`, ссылка Tududi или описание) —
прочитай её файл и связанные спеки/ADR/черновики.
- Если не задана — покажи топ-кандидатов из `docs/backlog/README.md` (высокий
приоритет, не `[идея]`) через **AskUserQuestion** и дай выбрать.
- Задача с префиксом `[идея]` (ещё без решения «делаем») — сперва обязательно
через explore (шаг 2), там она либо становится задачей, либо остаётся идеей.
Оцени тривиальность (влияет на шаг 4):
- **Тривиальная** — локальная правка без изменения поведения/спек/схемы БД,
очевидное решение. Explore и ревью спек пропускаем.
- **Нетривиальная** — новое/изменённое поведение, дизайн-развилки, затрагивает
инварианты, схему БД или несколько capability. Полный цикл.
### 2. (Опц.) Груммить идею — `opsx:explore`
Только для `[идея]`-задач или когда постановка мутная. Вызови Skill
`opsx:explore`. Развилки грумминга — на пользователя (AskUserQuestion). Выход:
ясная постановка, готовая к propose. **В explore не пишем код.**
### 3. Завести change — `opsx:propose`
Вызови Skill `opsx:propose`. Получаем `proposal.md`, дизайн (для нетривиальных),
дельта-спеки (`ADDED`/`MODIFIED`/`REMOVED Requirements`), `tasks.md`. Каждое
`### Requirement` содержит `SHALL`/`MUST`; структурные заголовки английские,
сценарии `GIVEN/WHEN/THEN`. Прогони `openspec validate --strict <id>`.
### 4. (Нетривиальная) Ревью спек — сабагент, ДО кода
Первый чекпоинт ревью-процесса из CLAUDE.md. Запусти **один** сабагент
`jellybit-review-specs` (Agent tool, `subagent_type`) в режиме «дизайн/спеки ДО
кода». Charter самодостаточен — дай ссылку на change `<id>`. Агент проверит
полноту покрытия, сценарии `GIVEN/WHEN/THEN`, scope, инварианты безопасности
данных, согласованность со спеками и capability-нарезкой, наличие `SHALL`/`MUST`.
### 5. Отработать замечания ревью спек
- Мелочь и явные улучшения — правь сам в спеках/дизайне.
- Развилки (компромисс, scope, инвариант) — на пользователя (AskUserQuestion).
- После правок перепрогони `openspec validate --strict <id>`.
### 6. Написать код — `opsx:apply`
Вызови Skill `opsx:apply` для реализации `tasks.md`. Код по конвенциям
`docs/conventions/*`: ошибки stdlib с `%w`/`errors.Is`, логи только `slog` без
секретов, время в UTC через `store.Now()`, ULID через `internal/ident`, миграции
goose + синк ER-схемы `docs/specs/database.md`, htmx по web-ui-конвенции.
Прогони `task test` и `task lint` (или `task build`), добейся зелёного.
**Поведенческая верификация (нетривиальные задачи с рантайм-поверхностью).** Если
задача меняет реальное поведение (новый флоу, схема БД, эндпоинт/htmx-путь, разбор
входа) — зелёных юнит-тестов мало: прогони через Skill **`verify`**, чтобы
прокатить изменение end-to-end и увидеть его вживую, а не только в тестах.
Пропусти для чисто внутренних правок без наблюдаемого рантайма (рефактор, доки,
правка только тестов). Под `task-batch` verify идёт в worktree задачи — портами/БД
не конфликтуй с соседними прогонами.
### 7. Ревью кода — сабагент(ы)
Второй чекпоинт. Ревьюеры — кастомные агенты из `.claude/agents/` (запускай их
через Agent tool с `subagent_type`). Число зависит от тривиальности:
- **Тривиальная задача — один сабагент** `jellybit-review-code`. В промпте
добавь просьбу дополнительно **бегло сверить соответствие дельта-спекам и
tasks.md** (он единственный, покрывает и спеки, и конвенции).
- **Нетривиальная — два параллельных сабагента одним сообщением**, чтобы шли
конкурентно: `jellybit-review-specs` (оптика спек) и `jellybit-review-code`
(оптика архитектуры/конвенций/стиля).
Charter'ы агентов самодостаточны — детальный промпт писать не нужно, дай ссылку
на change (`<id>`) и diff/список файлов (`git diff`).
Отработай так же, как шаг 5: мелочь чини инлайн, развилки — на пользователя.
После правок — снова `task test`/`task lint`.
### 8. Архивировать — `opsx:archive`
Вызови Skill `opsx:archive`: change уезжает в `openspec/changes/archive/`,
дельты вливаются в `openspec/specs/`.
### 9. Закрыть беклог и синк доков
Ревью выполненного — **до** чистки (память `review-before-backlog-cleanup`).
Затем:
- Удали файл задачи `docs/backlog/<slug>.md` и строку в `docs/backlog/README.md`
(реализованное не держим в беклоге — CLAUDE.md).
- Суть переехавшего решения — в `docs/specs`/`docs/adr`, если ещё не там.
- Если менялась структура БД — убедись, что ER-схема `docs/specs/database.md`
обновлена в этом же change.
### 10. Коммит
Коммить **в текущую ветку** (`git rev-parse --abbrev-ref HEAD`), сам ветку не
создавай и не переключай, ничего не пушь. Это работает в обоих режимах:
- **Ручной запуск** — HEAD обычно на `master`, коммит идёт прямо в него, без
feature-веток (память `commit-directly-to-master`).
- **Под оркестратором `task-batch`** — HEAD на ветке задачи в изолированном
worktree (`task/<slug>`); коммит идёт туда, а слияние в `master` через rebase/ff
делает оркестратор. Ничего дополнительно делать не нужно.
Сообщение — по-русски, в стиле недавних коммитов (`git log --oneline -8`): область
+ суть. Одна задача — один осмысленный коммит (или несколько по фазам, если так
шёл apply).
Готово — доложи пользователю кратко: что сделано, какие развилки решались, ссылки
на архивный change и спеки.
## Тонкости
- **Не завязывайся на master и корень репо.** Скилл работает в текущем worktree и
на текущей ветке: не делай `git checkout`/`switch`, не создавай веток, не
пушь. При одиночном запуске это master, под `task-batch` — ветка задачи в своём
worktree; поведение одинаковое.
- Не пропускай `openspec validate --strict` перед архивацией.
- Тривиальная задача: шаги 2 и 4 пропускаются; ревью кода (шаг 7) оставляем, но
одним сабагентом на всё. Два параллельных ревьювера — только на нетривиальных.
- Если сабагент-ревьюер сам предлагает крупную переработку — это развилка, не
правь молча, вынеси пользователю.
- Держи пользователя в цикле короткими репликами на переходах фаз, но не проси
подтверждать механику.
+66 -1
View File
@@ -1,11 +1,62 @@
# Конфиг golangci-lint (схема v2; устанавливается через `task setup`). # Конфиг golangci-lint (схема v2; устанавливается через `task setup`).
#
# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign, # Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign,
# staticcheck, unused; дополнительно включаем misspell. # staticcheck, unused. Сверх него включены линтеры, которые механизируют
# конвенции из docs/conventions/*: то, что проверяет правило, не должно
# оставаться прозой в конвенциях и в промптах ревью (процедура промоута —
# references/promote.md скилла av-dev-pipeline:review-pipeline; перечень уже
# механизированного — docs/conventions/README.md).
version: "2" version: "2"
linters: linters:
enable: enable:
- misspell - misspell
# docs/conventions/logging.md: msg — константная категория, данные — в
# полях, единый стиль ключ-значение.
- sloglint
# docs/conventions/logging.md (без fmt.Println), config.md (конфиг только
# из TOML, env не используем), database.md (время — только store.Now()).
- forbidigo
# docs/conventions/errors.md: сравнение ошибок через errors.Is/As, а не
# `err == ErrX` и не приведением типа.
- errorlint
# docs/conventions/errors.md: ошибки — только stdlib.
- depguard
settings:
sloglint:
no-mixed-args: true # не мешать пары «ключ-значение» с slog.Attr
kv-only: true # принятый в проекте стиль вызова
static-msg: true # msg — константа, без fmt.Sprintf и интерполяции
# key-naming-case НЕ включаем: словарь полей намеренно смешанный —
# доменные поля snake_case, системные домены с точкой (`http.method`,
# `ext.service`, адаптация OpenTelemetry). См. logging.md, «Поля».
forbidigo:
forbid:
- pattern: ^fmt\.Print.*$
msg: логируем через slog, в stdout напрямую не пишем (docs/conventions/logging.md)
- pattern: ^os\.Getenv$
msg: конфигурация только из TOML, env для конфига не используем (docs/conventions/config.md)
- pattern: ^time\.Now$
msg: время генерирует store.Now() (UTC, единая точка) — docs/conventions/database.md
errorlint:
# Обёртка вида fmt.Errorf("%w: %v", ErrSentinel, err) осознанна: sentinel
# раскрываем для errors.Is, причину — намеренно нет (errors.md, «%w vs %v»).
errorf: false
asserts: true
comparison: true
depguard:
rules:
main:
deny:
- pkg: github.com/pkg/errors
desc: ошибки — только stdlib errors + fmt.Errorf (docs/conventions/errors.md)
- pkg: github.com/cockroachdb/errors
desc: стек-трейсы избыточны, контекст несёт slog (docs/conventions/errors.md)
exclusions: exclusions:
generated: lax generated: lax
presets: presets:
@@ -17,6 +68,20 @@ linters:
- third_party$ - third_party$
- builtin$ - builtin$
- examples$ - examples$
rules:
# CLI — другая поверхность: печатает результат в stdout и меряет
# длительность своей работы, это не логирование и не время в БД.
- path: ^cmd/
linters: [forbidigo]
# Интеграционные тесты берут креды внешних сервисов из окружения —
# это не конфигурация приложения.
- path: _test\.go$
text: os.Getenv
linters: [forbidigo]
# Единые точки генерации id и времени — им time.Now по определению можно.
- path: ^internal/(ident|store)/
text: time.Now
linters: [forbidigo]
formatters: formatters:
exclusions: exclusions:
-73
View File
@@ -1,73 +0,0 @@
# Jellybit - краткое описание
Jellybit - это небольшой сервис, который должен связать между собой qBittorrent и Jellyfin.
## Контекст
Моя потребность - скачивать фильмы через торренты и смотреть их на телевизоре или проекторе.
У меня есть отдельный небольшой медиа сервер. Для скачаивания я использую qBittorrent,
это проверенный стабильный торрент-клиент с богатой функциональностью.
Для просмотра фильмов и сериалов мне понравилось использовать Jellyfin.
Он позволяет подтягивать метаданные, делает красивые страницы для фильмов и сериалов,
отмечает просмотренное.
Чтобы их соединить, я пробовал использовать arr-стек: prowlarr, radarr, sonarr.
Но тут я столкнулся с трудностями:
- российскиз фильмов или сериалов нет в каталогах, все равно приходится добавлять вручную
- prowlarr плохо заточен под российские торрент-трекеры, иногда фильм есть, но он его не может найти
- сложные настройки качества релизов
- с аниме совсем все сложно, у меня так и не получилось нормально качать
- если загрузить торрент вручную, то приходится его добавлять в sonarr/radarr, иногда отдельными сериями.
Поэтому я решил сократить путь и сделать свое решение - jellybit.
Это связующий сервис, который решает мою конкретную задачу:
берет скачанные файлы из qBittorent и переименовывает их для библиотеки Jellyfin.
Кроме того, часто для торрентов я использую специального бота, который возвращает ответ в таком виде:
```
[1] #6514485 [rutracker], 2026-03-21 (https://hashurl.ru/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJodHRwczovL3J1dHJhY2tlci5vcmcvZm9ydW0vdmlld3RvcGljLnBocD90PTY1MTQ0ODUiLCJjaGF0X2lkIjoxMTc3NDM2OTksInJlZmVyZXIiOiI1YzA1NDhhODFmM2YzZDUzMWFhOCIsImV4cCI6MTc4MDA0NTUwMX0.2LfH4S4ohcV5wvbW17XK6YRbNExBZE8V4JmVIWLyeJo):
Дюна: Часть вторая / Dune: Part Two (Дени Вильнёв / Denis Villeneuve) [2024, США, Канада, фантастика, WEB-DL 2160p, HDR10+, Dolby Vision] Dub (Bravo Records Georgia, RHS, Jaskier, HDrezka) + MVO (LostFilm, TVShows, Jaskier) + AVO (Сербин, Яроцкий) + (Ukr) + Original (Eng) + Sub (Rus, Eng, Ukr)
✅ (проверено) | 34.82 GB
magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6&tr=http%3A%2F%2Fbt.t-ru.org%2Fann%3Fmagnet&dn=rutracker-topic-6514485
Открыть magnet в вашем клиенте (https://hashurl.ru/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJtYWduZXQ6P3h0PXVybjpidGloOjU0MUFEQ0ZGM0I2REQ1REJBNzA4OEVBODMzMTdEOUQ2RkFDMzMxRDYmdHI9aHR0cCUzQSUyRiUyRmJ0LnQtcnUub3JnJTJGYW5uJTNGbWFnbmV0IiwiY2hhdF9pZCI6MTE3NzQzNjk5LCJyZWZlcmVyIjoibV81YzA1NDhhODFmM2YzZDUzMWFhOCIsImV4cCI6MTc4MDA0NTUwMX0.5AxS0mC-1wvr4Y9mU0evWWd7-zQJd64UHDHMVPrCCxM)
или получить .torrent: /tr_5c054
Оцените раздачу:
👍: /g_eabdce или 👎🏿: /r_eabdce
[список файлов] (https://download.exfreedomist.com/files/541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6)
Следить: /us_5c054
Добавить в закладки: /mka_96423
cправка: /help, index (https://exfreedomist.com/stats/)
```
## Пожелания к сервису
Чтобы совсем сократить путь для добавления фильм в библиотеку jellybit должен быть еще и входной точкой,
которая примет торрент-файл или magnet-ссылку, дополнительный контекст и добавит загрузку в qBittorrent.
Отследит окончание загрузки и разложит готовые файлы для Jellyfin.
Соответственно, главные требования к jellybit такие:
- По торренту и файлам определять фильм, серии и описание
- Автоматически переименовывать скачанные файлы для библиотеки Jellyfin
- Быть точкой входа для добавления загрузок: торрент-файлы, magnet-ссылки и дополнительный контекст.
Контекст важен, потому что для распознавания я хочу использовать LLM, а контекст дополнительно к именам файлов и директорий должен помочь корректно определить фильм, сериал, сезон и прочую мета-информацию.
Кроме того для более точной работы Jellyfin можно использовать поиск по открытым базам и связь загрузки
с идентификатором из этой базы, например https://www.thetvdb.com/ и другие.
## Ссылки
Jellyfin Movies: https://jellyfin.org/docs/general/server/media/movies
Jellyfin Series (TV Shows): https://jellyfin.org/docs/general/server/media/shows
Umbar - мой медиасервер (пока без Jellyfin): `/home/av/projects/private/umbar`
+215 -127
View File
@@ -1,116 +1,88 @@
# CLAUDE.md # CLAUDE.md
Памятка для работы над jellybit. Перед задачей прочитай также Памятка для работы над jellybit. Перед задачей прочитай также
[README.md](README.md), [BRIEF.md](BRIEF.md) и [docs/passport.md](docs/passport.md), [docs/architecture.md](docs/architecture.md)
[docs/specs/architecture.md](docs/specs/architecture.md). Разработка идёт и [docs/conventions/](docs/conventions/README.md). Разработка идёт по **Spec
по **Spec Driven Development** через OpenSpec — см. раздел ниже. Driven Development** через OpenSpec — см. раздел ниже.
## Что это ## Что это
Связующий сервис qBittorrent ↔ Jellyfin: принимает торрент + контекст, Связующий сервис qBittorrent ↔ Jellyfin: принимает торрент с текстовым
качает, распознаёт фильм/сериал (LLM + контекст + опц. метабазы) и контекстом, качает через qBittorrent, распознаёт фильм или сериал (LLM +
раскладывает файлы для Jellyfin хардлинками. Деплоится на домашний контекст + опц. метабазы) и раскладывает файлы для Jellyfin хардлинками, не
медиа-сервер umbar (`/home/av/projects/private/umbar`) — туда копируется трогая исходную раздачу. Деплоится на домашний медиа-сервер umbar
готовый бинарь. (`/home/av/projects/private/umbar`).
## Стек и принципы **Чего не делает:** не ищет раздачи в трекерах, не ведёт профили качества, не
подписывается на выходящие серии, не хранит медиа и не заменяет Jellyfin.
Полная граница домена — [docs/passport.md](docs/passport.md).
- **Go**, один статический бинарь (`CGO_ENABLED=0`). Почему — см. ## Стек
[ADR-2026-06-13-go-single-binary](docs/adr/ADR-2026-06-13-go-single-binary.md).
- **SQLite** как хранилище (чистый Go-драйвер `modernc.org/sqlite`).
- **Конфигурация — TOML**. **Логи — структурированный JSON** (`log/slog`).
- **Хардлинки, источник не трогаем** — qBittorrent продолжает раздачу,
диск не дублируется.
- **Единое ядро, тонкие транспорты** — вся логика приёма в use-case
`Ingest`; HTTP API, веб-UI и Telegram — лишь обёртки над ним.
- **Минимум компонентов** — в духе umbar, без зоопарка сервисов. Внешние
базы метаданных (TMDB/TVDB) опциональны, включаются конфигом.
## Инварианты (безопасность данных) Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module path —
`git.vakhrushev.me/av/jellybit`. SQLite через `modernc.org/sqlite` + `sqlx`,
миграции `goose`, HTTP — `chi` + `html/template` + htmx, конфиг —
`pelletier/go-toml/v2`, разбор `.torrent` и инфохэшей — `anacrolix/torrent`,
пред-парс имени раздачи — `middelink/go-parse-torrent-name`, логи — `log/slog`
(структурированный JSON).
- **Источник неприкосновенен:** только `mkdir` / `link(2)` / `unlink` ## Инварианты
своих ссылок; никогда не трогаем файлы под `paths.downloads`.
- **Целевой путь санитизируется** и проверяется, что он строго под
`paths.movies`/`series` (защита от traversal); существующее не
перезаписываем.
- **Выход LLM недоверенный** — безопасность на валидации пути, не на
промпте. Авто-раскладка только при подтверждённом матче в базе.
- **Секреты не попадают в логи** — пароли qBittorrent, API-ключи LLM/метабаз,
auth-заголовки. Подробнее — [docs/conventions/logging.md](docs/conventions/logging.md).
- **Запуск:** контейнер под `1000:1000`, в общей docker-сети (адресация
по именам), mount `/srv/media` (единая песочница) + data-том для
SQLite/конфига.
## Spec Driven Development (OpenSpec) Нарушать нельзя. Severity стоит здесь, а не выводится каждым проходом ревью
заново.
Изменения ведём через [OpenSpec](https://github.com/Fission-AI/OpenSpec) - **Источник неприкосновенен** — под `paths.downloads` допустимы только чтение
(CLI `openspec`, v1.x). Сначала спецификация — потом код. и `link(2)`; никаких `unlink`, `rename`, записи. Нарушение уничтожает
невосстановимые данные пользователя. **Необратимо. `critical`.**
- `openspec/specs/<capability>/spec.md`**актуальные** capability-спеки: Исключения два, и оба — не наши операции с файловой системой, а вызов
что система делает сейчас. Capability — это поведение/домен (`ingest`, `torrents/delete` qBittorrent с `deleteFiles=true`: (1) `Delete` из
`recognition`, `file-layout`, `review`, `notifications`), а не пакет кода. `done`/`orphaned`/`target_missing` по явному подтверждению человека — гард
- `openspec/changes/<id>/` — предлагаемое изменение: `proposal.md` (зачем и последней копии там выключен сознательно; подтверждение допустимо **одно на
что), `design.md` (как, для нетривиальных), дельта-спеки (`ADDED`/ пачку**, если называет каждую загрузку поимённо, и условия допуска групповой
`MODIFIED`/`REMOVED Requirements`), `tasks.md` (шаги). После реализации путь не смягчает
change архивируется в `openspec/changes/archive/`, дельты вливаются в ([state-reconciliation](openspec/specs/state-reconciliation/spec.md),
`openspec/specs/`. [web-ui](openspec/specs/web-ui/spec.md));
- `openspec/config.yaml` — язык и правила оформления спек (читай перед (2) уборка воркером **собственного** торрента, добавленного этим же `add`
написанием). секундами ранее, когда закрытие **любым** путём (`Cancel` или `Dismiss`)
увело задачу из `catched` в окне после `add` — уборка привязана к состоянию,
Поток работы — через слэш-команды `opsx:*` (канонический набор, его а не к команде; признак «своё» даёт подтверждённое отсутствие инфохэша
поддерживает `openspec update`): `opsx:explore` (продумать), `opsx:propose` непосредственно перед `add`
(завести change), `opsx:apply` (реализовать tasks), `opsx:sync`/`opsx:archive` ([download-tracking](openspec/specs/download-tracking/spec.md),
(влить и архивировать). Skills `openspec-*` — то же, но предыдущего [state-reconciliation](openspec/specs/state-reconciliation/spec.md)). Всё
поколения; для новой работы используем `opsx:*`. остальное под `paths.downloads` — по-прежнему `critical`.
- **Последняя копия не снимается** — `Undo` отклоняется целиком, если у цели не
Правила спек: осталось других жёстких ссылок (`nlink <= 1`) или исходного файла уже нет.
Частичный откат тоже стёр бы часть данных. **Необратимо. `critical`.**
- Каждое `### Requirement` ОБЯЗАНО содержать литерал `SHALL` или `MUST` - **Целевой путь строго под библиотекой** — после санитизации и
иначе `openspec validate` падает. `filepath.Clean` путь обязан лежать под `paths.movies`/`paths.series`, иначе
- Структурные заголовки и ключевые слова — английские (`### Requirement:`, операция отклоняется. Выход за песочницу означает запись в чужие каталоги.
`#### Scenario:`, `GIVEN/WHEN/THEN`, RFC 2119), остальной текст — русский. **Необратимо. `critical`.** Выход LLM недоверенный: безопасность держится на
- Сценарии — в формате `GIVEN/WHEN/THEN`. этой проверке, а не на промпте.
- `openspec validate --strict` перед коммитом change. - **Существующее не перезаписываем** — цель занята другим файлом → коллизия →
review. Обратимо (задача уходит в ревью), но потеря чужого файла — нет.
Ревью (процесс, не артефакт): нетривиальная задача — два чекпоинта (ревью **`critical`.**
дизайна после design/specs, ДО кода; ревью кода после apply, до archive); - **Секреты не попадают в логи, диагностику и ответы API** — пароль
тривиальная — одного прохода по коду достаточно. qBittorrent, ключи LLM и метабаз, токен Telegram, API-ключ Jellyfin.
Утёкший в лог секрет отзывается вручную. **`major`.**
**Миграция:** capabilities постепенно переносятся из `docs/specs/` в - **Авто-раскладка только при подтверждённом матче в метабазе** — самооценка
OpenSpec (пилот — `ingest`). До переноса источник истины по теме — LLM **единственным** гейтом не является и матч не заменяет: порог
соответствующий файл в `docs/specs/`; перенесённое живёт в `[recognition].auto_confidence_threshold` стоит поверх матча дополнительным
`openspec/specs/`. условием
([ADR](docs/adr/ADR-2026-06-13-auto-link-requires-db-match.md),
## Прочая документация [recognition](openspec/specs/recognition/spec.md)). Обратимо
через `Undo`. **`major`.**
- `docs/specs/`**живые** спецификации целевого состояния (архитектурный - **Не более одной активной загрузки на infohash** — проверка отсутствия другой
обзор + ещё не перенесённые в OpenSpec темы). Меняем по мере развития, активной загрузки и вставка идут одной write-транзакцией (`_txlock=immediate`,
держим в соответствии с кодом. guarded-методы `store`); обход даёт две задачи, претендующие на одну раздачу и
- `docs/adr/`**неизменяемый** журнал решений, пишется постфактум, один целевой путь. Обратимо (лишняя закрывается), но состояние расходится.
хранит *почему*. Правила — [docs/adr/README.md](docs/adr/README.md). **`major`.** Поведение — [ingest](openspec/specs/ingest/spec.md).
- `docs/drafts/` — черновики: планы, идеи, ещё не принятые решения. Не - **Переходы состояний — только через `worker` под per-download блокировкой**,
источник истины. и только легальные по декларативному графу. Обход даёт гонку двух
транспортов. **`major`.**
## Задачи и беклог - **Время — только `store.Now()` (UTC), идентификаторы — только `ident`**;
`ident.Parse` на каждой входной границе. Время механизировано линтером
- **Единственный источник беклога — каталог [docs/backlog/](docs/backlog/README.md)**: (`forbidigo` на `time.Now`); правило про `ident` линтером не проверяется —
одна задача = один markdown-файл (`docs/backlog/<slug>.md`), плюс держится на ревью. **`minor`.**
индекс [README.md](docs/backlog/README.md) со списком по приоритетам
(высокий/средний/низкий) и хуками. В теле файла — контекст, принятые
решения, шаги и ссылки на спеки/ADR/черновики. Заводи задачу новым файлом
и строкой в индексе; закрытую (реализованную) — удаляй, суть переезжает в
`docs/specs`/`docs/adr`.
- Спекулятивные задачи (ещё без решения «делаем») помечены префиксом
`[идея]` в названии — их сперва прорабатываем.
- **Tududi — только инбокс сырых идей** (проект `jellybit`, project_id 14).
Беклог там больше не ведём; идея из Tududi становится задачей, когда её
оформляют файлом в `docs/backlog/`. Прежняя единая `docs/backlog.md`
доступна в истории git.
## Язык
- Документация, комментарии, сообщения коммитов — **русский**.
- Код и идентификаторы — английский.
## Команды ## Команды
@@ -120,37 +92,153 @@ OpenSpec (пилот — `ingest`). До переноса источник ис
- `task run` — локальный запуск (`go run ./cmd/jellybit --config ./config.toml`) - `task run` — локальный запуск (`go run ./cmd/jellybit --config ./config.toml`)
- `task build` — статический бинарь `linux/amd64` для сервера - `task build` — статический бинарь `linux/amd64` для сервера
- `task test` / `task lint` — тесты и golangci-lint - `task test` / `task lint` — тесты и golangci-lint
- `task gate` — детерминированный гейт ревью (см. ниже)
- `task review:context` — карта проекта для архитектурного прохода ревью
- `task tidy``go mod tidy` - `task tidy``go mod tidy`
- `task image` — docker-образ из готового бинаря - `task image` — docker-образ из готового бинаря
Module path — `git.vakhrushev.me/av/jellybit`. Go 1.26, `CGO_ENABLED=0`. ## Гейт
Стек: `chi`, `sqlx` + `modernc.org/sqlite`, `goose` (миграции),
`pelletier/go-toml/v2`, `log/slog`. - **Команда целиком:** `task gate` (`BASE=<rev>` задаёт базу диффа). Без `BASE`
база — `git merge-base HEAD master`, а на самом `master``HEAD~1`.
- **Где логи шагов:** `tmp/gate/<шаг>.log`, по одному файлу на шаг.
- **Что означает исход:** статусы `OK` / `FAIL` (краснит) / `WARN` (виден, не
блокирует) / `SKIP` (не применим, всегда с причиной). Код возврата 1, если
есть хоть один `FAIL`. Гейт **не** останавливается на первом отказе —
ревьюверу нужна полная картина.
- **Что красит безусловно:** сборка, `go vet`, `golangci-lint`, `gofmt`, тесты,
флаки-прогон (второй прогон разошёлся с первым), `-race`, накат миграций с
нуля, `gitleaks`, канон документации (`docs.py check` — раскладка `docs/`,
битые ссылки, «миграция изменена, а `database.md` нет»), каталог задач
(`tasks.py check --dir tasks` — согласованность `tasks/BACKLOG.md` и
`tasks/items/`), форма конфига OpenSpec (`openspec.py check` — незаменённый
пример в `openspec/config.yaml`). Причина одна: у каждого из них есть
объективный оракул, спорить не о чем. Каждый из трёх последних краснеет и
когда своего скрипта нет: молча пропущенная проверка неотличима от пройденной.
- **Чего в гейте намеренно нет и кто обязан это гонять:**
- `govulncheck` даёт `WARN`, а не `FAIL`: находка тут — состояние
зависимостей, а не диффа. Разбирает агент ревью по трассам вызовов.
- `-race` без gcc уходит в `SKIP` с явным «гонки НЕ проверены» — тогда их
проверяет рассуждением тема `operations` ([docs/review.md](docs/review.md)
→ «Вопросы по темам»), и это идёт в границы покрытия.
- Ничего не гоняется против **живого** qBittorrent, LLM и метабаз:
интеграционные тесты за env-гейтами, запускает человек вручную.
- Качество распознавания гейтом не проверяется вовсе и проверяться не будет:
размеченный корпус решено не собирать (`tasks/REJECTED.md`,
2026-08-06). Сдвиг точности виден только по рабочему потоку.
## Запреты
- **Не запускать против рабочей БД** `/data/jellybit.db` на umbar и против
любого файла, на который указывает боевой `[storage].db_path`. Локально —
только `./jellybit.db`.
- **Не писать в `/srv/media/downloads`** и вообще никуда под `paths.downloads`:
там живут раздачи, которые qBittorrent продолжает сидировать.
- **Не ходить в боевой qBittorrent, Jellyfin и Telegram-бота** из тестов и
отладочных прогонов. Интеграционные тесты — за env-гейтами
(`*_integration_test.go`), включает человек осознанно.
- **Не расходовать лимиты метабаз и платного LLM** прогонами «посмотреть, что
будет»: у `recognize --dry-run` есть цена.
- **`testdata`** отдельным каталогом не заводился: фикстуры чужих форматов
живут константами в тестах пакета-разборщика (`internal/tgbot/parse_test.go`,
`internal/magnet`, `internal/torrent`).
- **Временное — только в `tmp/`** (в `.gitignore`); туда же пишет гейт. Не в
`/tmp`, не рядом с исходниками.
## Работа
- **Основная ветка:** `master`. От неё считается база диффа
(`git merge-base HEAD master`), в неё вливает батч, от неё ветвятся задачи.
- **Необратимое** (спрашивается у человека всегда): всё, что пишет в
`paths.downloads` или удаляет оттуда; удаление раздачи из qBittorrent вместе
с файлами (`Delete`) — кроме уборки собственного, только что добавленного
торрента, когда закрытие любым путём увело задачу из `catched` (см.
исключения инварианта выше); снятие последней копии
данных; правка уже применённой миграции; `git push --force`; удаление или
перезапись файла в библиотеке Jellyfin, которого мы не создавали.
- **Что считается сломанным:** покрасневший `task gate` на `master`. Пока он
красный, ни одна задача не считается сделанной, и чинится он раньше любой
другой работы: гейт один на все задачи.
- **Приоритет — это порядок строк в [tasks/BACKLOG.md](tasks/BACKLOG.md).**
Первая строка секции — то, что делают следующим. Порядок назначает человек на
груминге (`av-dev-tasks:groom`), машина его не выводит.
- **Ориентир по размеру порции разбора на груминге:** 5–8 задач. Ориентир, а не
закон.
- **Что такое «сделана»:** пайплайн задачи пройден целиком (спека → код → оба
чекпоинта ревью → archive) и критерии приёмки проверены поимённо.
## Spec Driven Development (OpenSpec)
Изменения ведём через [OpenSpec](https://github.com/Fission-AI/OpenSpec)
(CLI `openspec`, v1.x). Сначала спецификация — потом код.
- `openspec/specs/<capability>/spec.md`**нормативный дом поведения**: что
система делает сейчас. Capability — это поведение или домен системы, а не
пакет кода.
- `openspec/changes/<id>/` — предлагаемое изменение: `proposal.md`, `design.md`
(для нетривиальных), дельта-спеки (`ADDED`/`MODIFIED`/`REMOVED Requirements`),
`tasks.md`. После реализации change архивируется в
`openspec/changes/archive/`, дельты вливаются в `openspec/specs/`.
- `openspec/config.yaml` — только нужды генерации артефактов: язык, правила
именования capability, придирки валидатора — плюс адреса документов канона.
Пересказа этих документов там нет: второй дом факта расходится молча.
Поток работы — через слэш-команды `opsx:*`: `opsx:explore` (продумать),
`opsx:propose` (завести change), `opsx:apply` (реализовать tasks),
`opsx:sync`/`opsx:archive` (влить и архивировать).
Правила спек — язык, именование capability и придирки валидатора — живут в
[openspec/config.yaml](openspec/config.yaml) (`context` и `rules`), оттуда их
читает порождение артефактов; здесь не дублируются. Перед коммитом change —
`openspec validate --strict`.
Ревью — два чекпоинта: ревью дизайна на предложении (после design/specs, ДО
кода) и ревью изменения после apply, до archive. Состав обоих выбирается по
метке задачи (`small` / `medium` / `large`), которую разметка ставит один раз
после propose. Настройка конвейера под проект и журнал дефектов —
[docs/review.md](docs/review.md).
## Документация
Раскладка задана каноном av-dev; проверяет её `docs.py check` внутри `task gate`.
- [docs/passport.md](docs/passport.md) — зачем и для кого, чем **не** является.
- [docs/architecture.md](docs/architecture.md) — обзор, эксплуатация, единые
точки, деплой. **Поведения здесь нет** — оно в `openspec/specs/`.
- [docs/database.md](docs/database.md) — схема, представление данных, настройки
с числовым значением.
- [docs/security.md](docs/security.md) — периметр, недоверенный вход, что вне
модели.
- [docs/conventions/](docs/conventions/README.md) — как пишем код.
- [docs/research/](docs/research/README.md) — наблюдения за чужими форматами.
- [docs/adr/](docs/adr/README.md) — журнал решений, неизменяемый.
- [docs/review.md](docs/review.md) — настройка конвейера ревью и журнал дефектов.
- [tasks/](tasks/BACKLOG.md) — задачи и цели: одна запись = один файл
в `items/` + строка в индексе, порядок строк = приоритет. Ведётся скиллом
`av-dev-tasks:tasks`, разбор беклога — `av-dev-tasks:groom`.
**Tududi** (проект `jellybit`, project_id 14) — только инбокс сырых идей. Идея
становится задачей, когда её оформляют файлом в `tasks/items/`.
## Конвенции кода ## Конвенции кода
- Раскладка: `cmd/jellybit` (точка входа) + `internal/<пакет>` по - Раскладка: `cmd/jellybit` (точка входа) + `internal/<пакет>` по компонентам
компонентам из [architecture.md](docs/specs/architecture.md). из [docs/architecture.md](docs/architecture.md).
- Ошибки — stdlib, обёртка с контекстом (`fmt.Errorf("...: %w", err)`), - **Механизируемое проверяет `task gate`** (`.golangci.yml` +
проверка через `errors.Is`/`errors.As`, трансляция на внешней границе: `internal/archrules`): форма ошибок и логов, конфиг мимо env, время мимо
[docs/conventions/errors.md](docs/conventions/errors.md). `store.Now()`, `AUTOINCREMENT` в миграциях, направление зависимостей
- Логирование только через `slog`, без `fmt.Println` — уровни, обязательные ядро↔транспорты. Перечень с местом механизации —
поля и что не логировать см. [docs/conventions/logging.md](docs/conventions/logging.md). [docs/conventions/README.md](docs/conventions/README.md); пересказывать эти
- Конфигурация — только TOML; секреты рендерит деплой (Ansible+Vault) в правила прозой не нужно.
файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте: - Прозой остаётся только то, что правилом не выражается, и читается в
[docs/conventions/config.md](docs/conventions/config.md). источнике: [ошибки](docs/conventions/errors.md),
- Время — храним в UTC, RFC 3339 с суффиксом `Z`; генерирует только приложение [логи](docs/conventions/logging.md), [конфиг](docs/conventions/config.md),
(`store.Now()`), таймзона отображения — конфиг `[general].timezone`: [БД](docs/conventions/database.md), [веб-UI](docs/conventions/web-ui.md).
[docs/conventions/database.md](docs/conventions/database.md).
- Идентификаторы — TEXT ULID (lowercase) через `internal/ident`, без числовых
AUTOINCREMENT; внешние id валидируются `ident.Parse` на границе:
[docs/conventions/database.md](docs/conventions/database.md).
- Миграции БД (goose, `internal/store/migrations`; SQL для DDL, Go — когда - Миграции БД (goose, `internal/store/migrations`; SQL для DDL, Go — когда
нужен код) при изменении структуры (таблица/столбец/индекс/связь) в том же нужен код): при изменении структуры в том же change обновляем ER-схему в
change обновляем ER-схему [docs/specs/database.md](docs/specs/database.md). [docs/database.md](docs/database.md) — иначе краснеет шаг `canon` гейта.
- Веб-UI на htmx — единый партиал = страница = фрагмент, ветвление по `isHTMX`,
деградация без JS, ошибка на htmx-пути = 200 + фрагмент, самозавершающийся
поллинг: [docs/conventions/web-ui.md](docs/conventions/web-ui.md).
Кросс-каттинг конвенции (как пишем код, а не что система делает) живут в ## Язык
[docs/conventions/](docs/conventions/README.md) и не переносятся в OpenSpec.
- Документация, комментарии, сообщения коммитов — **русский**.
- Код и идентификаторы — английский.
+5 -2
View File
@@ -1,5 +1,8 @@
# Упаковка готового статического бинаря в минимальный образ. # Упаковка готового статического бинаря в минимальный образ. Образ целиком
# Бинарь собирается снаружи (см. docs/adr/ADR-2026-06-13-docker-deploy.md): # собирается локально на control-хосте (`task image`) и едет на сервер через
# docker save/load — роль app_image в umbar (см.
# docs/adr/ADR-2026-07-24-local-image-build.md).
# Бинарь собирается снаружи:
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit # CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit
# distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь # distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь
# задаётся в compose (user: "1000:1000"). # задаётся в compose (user: "1000:1000").
+35 -34
View File
@@ -1,12 +1,13 @@
# Jellybit # Jellybit
Jellybit — связующий сервис между qBittorrent и Jellyfin. Принимает Jellybit — связующий сервис между qBittorrent и Jellyfin. Принимает
magnet-ссылку вместе с текстовым контекстом, ставит загрузку в magnet-ссылку или `.torrent`-файл вместе с текстовым контекстом, ставит загрузку в
qBittorrent, дожидается её завершения, распознаёт содержимое (фильм или qBittorrent, дожидается её завершения, распознаёт содержимое (фильм или
сериал, сезоны и серии) и раскладывает готовые файлы по конвенциям сериал, сезоны и серии) и раскладывает готовые файлы по конвенциям
библиотеки Jellyfin. библиотеки Jellyfin.
Полный замысел и причины — в [BRIEF.md](BRIEF.md). Полный замысел, границы домена и типовые сценарии — в
[docs/passport.md](docs/passport.md).
## Зачем ## Зачем
@@ -34,14 +35,10 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
## Статус ## Статус
Рабочий прототип с полным сквозным путём: приём magnet → загрузка в Рабочий прототип: сквозной путь приём → загрузка → распознавание → раскладка
qBittorrent → распознавание (LLM + опционально базы метаданных работает целиком, автоматически при уверенном результате либо через
TMDB/TVDB/TVMaze) → раскладка в библиотеку хардлинками, автоматически при подтверждение человеком. Что уже умеет и что дальше —
уверенном результате либо через подтверждение человеком. Транспорты приёма: [tasks/ROADMAP.md](tasks/ROADMAP.md).
REST API, веб-UI, Telegram-бот и CLI (`jellybit add`).
Из источников пока поддержан magnet; `.torrent` и обычные ссылки — в планах.
См. [дорожную карту](docs/drafts/roadmap.md).
## Документация ## Документация
@@ -49,26 +46,32 @@ REST API, веб-UI, Telegram-бот и CLI (`jellybit add`).
[OpenSpec](https://github.com/Fission-AI/OpenSpec): изменение сначала [OpenSpec](https://github.com/Fission-AI/OpenSpec): изменение сначала
описывается спекой, потом реализуется. описывается спекой, потом реализуется.
- [openspec/](openspec/) — OpenSpec: актуальные capability-спеки в - [openspec/specs/](openspec/specs/) — **что система делает**, нормативно:
`openspec/specs/`, изменения (proposal → design → tasks → archive) в capability-спеки. Изменения (proposal → design → tasks → archive) в
`openspec/changes/`. Capabilities постепенно переносятся сюда из `openspec/changes/`.
`docs/specs/`. - [docs/passport.md](docs/passport.md) — зачем и для кого, чем **не** является.
- [docs/conventions/](docs/conventions/) — конвенции кода (*как* пишем): - [docs/architecture.md](docs/architecture.md) — как сложено: компоненты,
логирование ([logging.md](docs/conventions/logging.md)), конфигурация внешние границы, эксплуатация, единые точки, деплой.
([config.md](docs/conventions/config.md)), ошибки - [docs/database.md](docs/database.md) — схема хранилища и настройки.
([errors.md](docs/conventions/errors.md)). - [docs/security.md](docs/security.md) — периметр и модель угроз.
- [docs/specs/](docs/specs/) — спецификации устройства системы - [docs/conventions/](docs/conventions/README.md) — как пишем код:
(архитектурный обзор + ещё не перенесённые в OpenSpec темы). Начать с [логи](docs/conventions/logging.md), [ошибки](docs/conventions/errors.md),
[architecture.md](docs/specs/architecture.md). [конфиг](docs/conventions/config.md), [БД](docs/conventions/database.md),
- [docs/adr/](docs/adr/) — журнал архитектурных решений (почему так). [веб-UI](docs/conventions/web-ui.md).
- [docs/drafts/](docs/drafts/) — черновики: планы, идеи, нерешённое. - [docs/adr/](docs/adr/README.md) — журнал решений (почему так), неизменяемый.
- [docs/research/](docs/research/README.md) — наблюдения за чужими форматами.
- [tasks/](tasks/BACKLOG.md) — задачи и цели.
Раскладка документации задана каноном av-dev и проверяется шагом `canon` в
`task gate`.
## Стек ## Стек
Go (один статический бинарь), SQLite (`modernc.org/sqlite` + `sqlx`, Go (один статический бинарь), SQLite (`modernc.org/sqlite` + `sqlx`,
миграции `goose`), HTTP — `chi` + `html/template` + htmx, конфигурация — миграции `goose`), HTTP — `chi` + `html/template` + htmx, конфигурация —
TOML, логи — структурированный JSON (`slog`). Подробнее — в TOML, логи — структурированный JSON (`slog`). Полный перечень с версиями —
[architecture.md](docs/specs/architecture.md). [CLAUDE.md](CLAUDE.md) → «Стек»; как эти компоненты сложены —
[docs/architecture.md](docs/architecture.md).
## Конфигурация ## Конфигурация
@@ -112,14 +115,12 @@ jellybit recognize <infohash> --dry-run [--context "..."] --config ./config.toml
## Доставка ## Доставка
Рассчитан на домашний медиа-сервер. Артефакты репозитория — статический Рассчитан на домашний медиа-сервер. Артефакты репозитория — статический бинарь
бинарь (`task build`) и `Dockerfile` (упаковка в `distroless/static`). Образ (`task build`) и `Dockerfile`; образ собирается целиком локально на
собирается **на сервере** из доставленного бинаря, поэтому Go-тулчейн на control-хосте (`task image`) и едет на сервер через `docker save`/`load`.
сервере не нужен. В distroless нет shell/curl, поэтому HEALTHCHECK зовёт сам
бинарь: `jellybit healthcheck` (GET `/healthz` по порту из конфига, exit 0/1).
Контейнер: `user 1000:1000`, порт `8080` на хост, mount `/srv/media` (единая
песочница для хардлинков) + том `/config` (ro, `config.toml`, восстановим при
деплое) + data-том `/data` (SQLite, бекапить); к qBittorrent — по сети Docker.
Конкретная деплой-обвязка (плейбук, секреты) держится в отдельном приватном Конкретная деплой-обвязка (плейбук, секреты) держится в отдельном приватном
репозитории и в комплект не входит. репозитории и в комплект не входит.
Параметры запуска — сеть, пользователь, монтирования, healthcheck, — разделение
ответственности с umbar и единая песочница `/srv/media`:
[docs/architecture.md](docs/architecture.md) → «Деплой».
+16 -4
View File
@@ -8,8 +8,9 @@ version: '3'
vars: vars:
BINARY: jellybit BINARY: jellybit
PKG: ./cmd/jellybit PKG: ./cmd/jellybit
# Версия линтера для воспроизводимой установки (см. задачу setup). # Версии инструментов для воспроизводимой установки (см. задачу setup).
GOLANGCI_VERSION: v2.12.2 GOLANGCI_VERSION: v2.12.2
GOVULNCHECK_VERSION: v1.6.0
tasks: tasks:
default: default:
@@ -40,6 +41,16 @@ tasks:
cmds: cmds:
- golangci-lint run - golangci-lint run
gate:
desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/канон docs/секреты. BASE=<rev> — база диффа'
cmds:
- python3 scripts/gate.py {{.BASE}}
review:context:
desc: 'Вход для архитектурного прохода ревью: пакеты, граф зависимостей, инвентарь концепций'
cmds:
- python3 scripts/review-context.py
tidy: tidy:
desc: go mod tidy desc: go mod tidy
cmds: cmds:
@@ -65,10 +76,10 @@ tasks:
done < web/assets.manifest done < web/assets.manifest
image: image:
desc: Docker-образ из готового бинаря (см. docs/adr docker-deploy) desc: 'Docker-образ из готового бинаря. Тег из $BUILD_ID (по умолчанию dev; роль app_image umbar передаёт свой). См. docs/adr local-image-build'
deps: [build] deps: [build]
cmds: cmds:
- docker build -t jellybit:dev . - docker build -t {{.BINARY}}:${BUILD_ID:-dev} .
clean: clean:
desc: Удалить собранный бинарь desc: Удалить собранный бинарь
@@ -76,7 +87,8 @@ tasks:
- rm -f {{.BINARY}} - rm -f {{.BINARY}}
setup: setup:
desc: Установка инструментов разработки (линтер + git-хуки lefthook) desc: Установка инструментов разработки (линтер, govulncheck + git-хуки lefthook)
cmds: cmds:
- go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@{{.GOLANGCI_VERSION}} - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@{{.GOLANGCI_VERSION}}
- go install golang.org/x/vuln/cmd/govulncheck@{{.GOVULNCHECK_VERSION}}
- lefthook install - lefthook install
+1
View File
@@ -95,6 +95,7 @@ func runRecognize(args []string) error {
rec := recognize.New(provider, providers, recognize.Config{ rec := recognize.New(provider, providers, recognize.Config{
MaxRetries: cfg.LLM.MaxRetries, MaxRetries: cfg.LLM.MaxRetries,
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold, AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
Language: cfg.ContentLanguage(),
}, logger) }, logger)
in := recognize.Input{Name: t.Name, Context: *contextStr} in := recognize.Input{Name: t.Name, Context: *contextStr}
+4 -2
View File
@@ -104,8 +104,9 @@ func runServe(args []string) error {
recognizer = recognize.New(llmProvider, providers, recognize.Config{ recognizer = recognize.New(llmProvider, providers, recognize.Config{
MaxRetries: cfg.LLM.MaxRetries, MaxRetries: cfg.LLM.MaxRetries,
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold, AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
Language: cfg.ContentLanguage(),
}, logger) }, logger)
logger.Info("recognizer ready", "model", cfg.LLM.Model, "providers", len(providers)) logger.Info("recognizer ready", "model", cfg.LLM.Model, "providers", len(providers), "language", cfg.ContentLanguage())
} else { } else {
logger.Warn("llm not configured, recognition disabled") logger.Warn("llm not configured, recognition disabled")
} }
@@ -277,6 +278,7 @@ func metadataProviders(cfg *config.Config, logger *slog.Logger) ([]metadata.Prov
APIKey: cfg.Metadata.TVDB.APIKey, APIKey: cfg.Metadata.TVDB.APIKey,
Proxy: cfg.Metadata.TVDB.Proxy, Proxy: cfg.Metadata.TVDB.Proxy,
Timeout: cfg.Metadata.TVDB.Timeout.Std(), Timeout: cfg.Metadata.TVDB.Timeout.Std(),
Language: cfg.ContentLanguage(),
}, logger) }, logger)
if err != nil { if err != nil {
return nil, fmt.Errorf("tvdb provider: %w", err) return nil, fmt.Errorf("tvdb provider: %w", err)
@@ -288,7 +290,7 @@ func metadataProviders(cfg *config.Config, logger *slog.Logger) ([]metadata.Prov
APIKey: cfg.Metadata.TMDB.APIKey, APIKey: cfg.Metadata.TMDB.APIKey,
Proxy: cfg.Metadata.TMDB.Proxy, Proxy: cfg.Metadata.TMDB.Proxy,
Timeout: cfg.Metadata.TMDB.Timeout.Std(), Timeout: cfg.Metadata.TMDB.Timeout.Std(),
Language: cfg.Metadata.TMDB.Language, Language: cfg.ContentLanguage(),
}, logger) }, logger)
if err != nil { if err != nil {
return nil, fmt.Errorf("tmdb provider: %w", err) return nil, fmt.Errorf("tmdb provider: %w", err)
+3 -3
View File
@@ -7,6 +7,7 @@
[general] [general]
# Общие настройки приложения. # Общие настройки приложения.
timezone = "UTC" # таймзона ОТОБРАЖЕНИЯ времени в веб-UI (IANA, напр. "Europe/Moscow"); хранение всегда в UTC. Пусто → UTC timezone = "UTC" # таймзона ОТОБРАЖЕНИЯ времени в веб-UI (IANA, напр. "Europe/Moscow"); хранение всегда в UTC. Пусто → UTC
language = "en" # язык локализованного вывода (title детектора + режиссёр/локаль метабаз): "ru" | "en". Пусто → en. original_title всегда на языке оригинала
[qbittorrent] [qbittorrent]
url = "http://qbit:8989" # адрес qBittorrent WebUI; в docker-сети — по имени сервиса url = "http://qbit:8989" # адрес qBittorrent WebUI; в docker-сети — по имени сервиса
@@ -42,14 +43,13 @@ max_retries = 3 # попыток получить вали
enabled = false # включить провайдера TMDB; без матча авто-раскладку не делаем enabled = false # включить провайдера TMDB; без матча авто-раскладку не делаем
api_key = "" # секрет: ключ TMDB; обязателен, если enabled (заполняет деплой) api_key = "" # секрет: ключ TMDB; обязателен, если enabled (заполняет деплой)
proxy = "" # опц. HTTP-прокси; пусто = без прокси proxy = "" # опц. HTTP-прокси; пусто = без прокси
timeout = "10s" # таймаут запроса к TMDB; Go-duration (s/m/h) timeout = "10s" # таймаут запроса к TMDB; Go-duration (s/m/h). Локаль названий задаёт [general].language
language = "ru-RU" # локаль названий в ответе TMDB; пусто = ru-RU
[metadata.tvdb] [metadata.tvdb]
enabled = false # включить провайдера TVDB enabled = false # включить провайдера TVDB
api_key = "" # секрет: ключ TVDB; обязателен, если enabled (заполняет деплой) api_key = "" # секрет: ключ TVDB; обязателен, если enabled (заполняет деплой)
proxy = "" # опц. HTTP-прокси; пусто = без прокси proxy = "" # опц. HTTP-прокси; пусто = без прокси
timeout = "10s" # таймаут запроса к TVDB; Go-duration (s/m/h) timeout = "10s" # таймаут запроса к TVDB; Go-duration (s/m/h). Локаль названий задаёт [general].language: в запрос поиска не уходит, применяется при разборе ответа
[metadata.tvmaze] [metadata.tvmaze]
enabled = false # включить провайдера TVMaze; без ключа, только сериалы (тег [tvdbid-…] из externals) enabled = false # включить провайдера TVMaze; без ключа, только сериалы (тег [tvdbid-…] из externals)
+4
View File
@@ -0,0 +1,4 @@
{
"canon": 12,
"migrations": "internal/store/migrations"
}
-17
View File
@@ -1,17 +0,0 @@
# Документация jellybit
Три раздела с разной ролью — не путать:
- **[specs/](specs/)** — спецификации. Описывают **целевое и текущее**
устройство системы. Живые и изменяемые: правим по мере развития,
держим в соответствии с кодом. Отвечают на вопрос «как устроено».
- **[adr/](adr/)** — Architecture Decision Records. **Неизменяемый**
журнал значимых решений, пишется **постфактум**. Хранит главное —
*почему* так сделано. Передумали → не правим старую запись, заводим
новую. Процесс — в [adr/README.md](adr/README.md).
- **[drafts/](drafts/)** — черновики: заметки, мысли, планы на будущее,
ещё не принятые решения. Не источник истины и ни к чему не обязывают.
Когда черновик становится реальностью — его место в specs (как
устроено) и/или adr (почему решили).
@@ -1,6 +1,9 @@
# Авто-раскладка только при подтверждённом матче в метабазе # Авто-раскладка только при подтверждённом матче в метабазе
- Дата: 2026-06-13 - **Дата:** 2026-06-13
- **Источник:** архивного `design.md` нет — решение принято до перехода
проекта на OpenSpec (первый архивный change — `2026-06-28`); первичный
материал — история git
## Контекст ## Контекст
@@ -21,7 +24,7 @@ jellybit распознаёт содержимое релиза через LLM
каноническое имя + `provider_id`. Но русские релизы и аниме часто в них каноническое имя + `provider_id`. Но русские релизы и аниме часто в них
отсутствуют. отсутствуют.
- Безопасность раскладки уже держится на валидации пути, не на промпте - Безопасность раскладки уже держится на валидации пути, не на промпте
(см. [recognition.md](../specs/recognition.md)); решение «авто vs review» — (см. [recognition](../../openspec/specs/recognition/spec.md)); решение «авто vs review» —
второй слой защиты, на уровне доверия результату. второй слой защиты, на уровне доверия результату.
## Рассмотренные варианты ## Рассмотренные варианты
@@ -53,8 +56,8 @@ LLM не противоречат по типу/названию/году. Не
подтверждает) и убирает целый класс тихих ошибок «модель уверенно подтверждает) и убирает целый класс тихих ошибок «модель уверенно
ошиблась». Review здесь — не наказание, а штатный режим для всего, что ошиблась». Review здесь — не наказание, а штатный режим для всего, что
база не подтвердила (петля «догадка → подсказка → перераспознавание», см. база не подтвердила (петля «догадка → подсказка → перераспознавание», см.
[review-ux.md](../specs/review-ux.md)). Полная модель уверенности — в [review](../../openspec/specs/review/spec.md)). Полная модель уверенности — в
[recognition.md](../specs/recognition.md). [recognition](../../openspec/specs/recognition/spec.md).
## Последствия ## Последствия
+5 -1
View File
@@ -1,6 +1,10 @@
# Docker как единица деплоя, образ собирается на сервере # Docker как единица деплоя, образ собирается на сервере
- Дата: 2026-06-13 - **Дата:** 2026-06-13
- **Источник:** архивного `design.md` нет — решение принято до перехода
проекта на OpenSpec (первый архивный change — `2026-06-28`); первичный
материал — история git
- **Статус:** заменено на ADR-2026-07-24-local-image-build
## Контекст ## Контекст
+4 -1
View File
@@ -1,6 +1,9 @@
# Go и доставка одним бинарём # Go и доставка одним бинарём
- Дата: 2026-06-13 - **Дата:** 2026-06-13
- **Источник:** архивного `design.md` нет — решение принято до перехода
проекта на OpenSpec (первый архивный change — `2026-06-28`); первичный
материал — история git
## Контекст ## Контекст
+4 -1
View File
@@ -1,6 +1,9 @@
# Хардлинки вместо копирования и симлинков # Хардлинки вместо копирования и симлинков
- Дата: 2026-06-13 - **Дата:** 2026-06-13
- **Источник:** архивного `design.md` нет — решение принято до перехода
проекта на OpenSpec (первый архивный change — `2026-06-28`); первичный
материал — история git
## Контекст ## Контекст
@@ -0,0 +1,81 @@
# Отдельную сущность «тайтл» не вводим
- **Дата:** 2026-07-02
- **Источник:** черновик `docs/drafts/logical-title-model.md` (разбор от
2026-07-01, переработан 2026-07-02; удалён при переводе проекта на канон,
полный текст — в истории git). Первый производный change —
[openspec/changes/archive/2026-07-02-ulid-identity/design.md](../../openspec/changes/archive/2026-07-02-ulid-identity/design.md).
## Решение
Логический тайтл (фильм или сериал, складывающийся из нескольких загрузок во
времени) остаётся **вычисляемой группой**, а не хранимой сущностью: доменная
идентичность — `download` (ULID + множество инфохэшей), связь с диском —
`file_link` с владением целевым путём, а «второй сезон в ту же папку» решается
**правилом сходимости папки** при построении плана раскладки.
## Почему
Разбор шёл от операций, и у тайтла их не нашлось:
> У `title` при разборе **не нашлось ни одной собственной операции**: сходимость
> папки — правило при построении плана; merge докачивания — per-path логика;
> удаление целиком — цикл по вычисляемой группе. Сущность без собственных
> операций — это линза, а линзу достаточно вычислять, не хранить.
Второй аргумент — у папки уже есть дом, и вычисляемый якорь **корректнее**
хранимого:
> «Папка — это title-уровневое состояние, ей нужен дом» разбивается о то, что
> дом у папки уже есть — файловая система и `dst_path` живых `file_link`'ов.
> Реестр дублировал бы то, что и так записано в БД в N экземплярах. Причём
> вычисляемый якорь корректнее хранимого: если все файлы сериала снесли, живых
> ссылок нет — и новая загрузка честно создаёт свежую папку; хранимый
> `title.folder` указывал бы в пустоту.
Третий — отказ **устраняет**, а не решает хвост развилок: жизненный цикл тайтла
(рождение, смерть, пустой тайтл), слияние тайтлов, ad-hoc тайтл без провайдера,
обратная миграция существующих строк, отдельный title-лог.
## Рассмотренные варианты
- **L2 — `title` с ключом `(provider, provider_id)`.** Отвергнут: привязывает
долгоживущую сущность к провайдеру, который может смениться.
- **L2-min — `title` со своим ULID + `title_external_id`** (провайдерные id
множеством-атрибутом, симметрично `download_infohash`). Схема красивая и
решает смену провайдера, ad-hoc тайтлы и слияние. Отвергнут именно по
аргументу выше: собственных операций нет, а сущность тянет жизненный цикл,
миграцию и четыре развилки.
- **L3 — title-центричная медиатека (модель sonarr).** Отвергнут осознанно: мы
не ходим в индексеры, не мониторим тайтлы и не ведём профили качества —
контент приносит пользователь. Это граница домена,
[passport.md](../passport.md) → «Что целью не является».
## Последствия
- `+` Идентичность осталась одноуровневой: `download` — мост между раздачей в
qBittorrent и файлами на диске, и каждая сущность цепочки отвечает на свои
операции.
- `+` Главная боль («второй сезон должен лечь в ту же папку») закрыта дешёвым
правилом при построении плана — реализовано change'ем
`2026-07-10-series-folder-convergence`, требования влиты в
[openspec/specs/file-layout](../../openspec/specs/file-layout/spec.md).
- `+` Устранён, а не отложен, хвост развилок вокруг жизненного цикла тайтла.
- `` Группировка тайтла в UI и «удалить тайтл целиком» придётся каждый раз
**вычислять** по `(provider, provider_id)` и общей папке; дешёвого хранимого
ключа для этого нет.
- `` Рассинхрон «несколько живых папок с одним `(provider, provider_id)`»
разрешается только уходом в review — in-app лечения нет, чинится руками на
диске.
- `` Слияние загрузок при перезаливе «той же вещи» осталось открытым: когда
несколько инфохэшей считать одной загрузкой, а когда разными, — вопрос
переехал в задачу про merge-раскладку.
## Триггер пересмотра
Записан отдельно, чтобы не гонять этот круг заново:
> Сущность `title` возвращается в обсуждение, только когда появится **операция
> или состояние, которому реально негде жить** в `download` + `file_link` —
> например, «переименовать сериал целиком с переносом ссылок» как регулярное
> действие или заметки уровня группы. До того — вычисляем.
@@ -0,0 +1,90 @@
# Конвейер ревью: гейт, generative-проходы и обязательный триаж
- **Дата:** 2026-07-23
- **Источник:** архивного `design.md` нет — решение процессное, change'ем jellybit
не велось
## Контекст
Ревью изменений вели два сабагента (`jellybit-review-specs`,
`jellybit-review-code`), вызываемые из `task-pipeline`. Оба устроены одинаково:
получают дифф и применяют записанный чек-лист — конвенции, инварианты, пункты
дельта-спеки. Инструменты им запускать было запрещено, детерминированные
проверки (`lefthook`, `task test`/`task lint`) шли отдельно и **после**
опиниативных проходов.
У такой конфигурации три ограничения, которые нельзя снять её же средствами.
**Recall чек-листа равен длине чек-листа.** Проход, устроенный как «проверь
пункты 1..N», находит ровно перечисленное. Всё неявное — форма решения,
идиоматичность, «так не делают» — неперечислимо по определению: то, что можно
выписать, уже стало бы конвенцией. Удлинение списков не помогает, а вредит:
внимание уходит на именование полей лога и не доходит до формы решения.
**Ценность верификатора определяется наличием внешнего оракула и декорреляцией
с автором, а не числом ролей.** Разделение на «архитектура / конвенции / стиль»
декоррелирует внимание, но не суждение: под всеми ролями одна модель с одними
априорными, и код писала она же. Бюджет уходил на мнения при непокрытом уровне
детерминированных инструментов (`-race`, покрытие изменённых строк, флаки,
`govulncheck` не гонялись вовсе).
**Отчёт без границ покрытия хуже отсутствия отчёта.** «Замечаний нет»
потребляло ощущение проверенности, ничего не гарантируя.
Дополнительно: сверка со спекой шла только в направлении spec → code, поэтому
поведение, которое код имеет, а дельта не заказывала, не ловилось никем — а это
системная болезнь агентского кода. Измерения качества ревью не существовало.
## Рассмотренные варианты
- **Дробить `jellybit-review-code` на узкие оптики** (архитектура / конвенции /
стиль) — так стоял открытый вопрос в беклоге. Отвергнуто: добавляет роли, не
добавляя ни оракула, ни декорреляции суждения; recall почти не растёт, а
стоимость триажа растёт линейно.
- **Удлинять чек-листы и конвенции** — прямо противоположно причине проблемы.
- **Заменить конвейер CI-проверками** — покрывает только выразимое правилом;
неявный слой остаётся непокрытым.
## Решение
Конвейер пересобран по типу проходов, а не по ролям, и оформлен скиллом
`.claude/skills/review-pipeline`.
1. **Детерминированный гейт первым** (`task gate`): агент запускает инструменты
и интерпретирует вывод, отличая новые отказы от унаследованных. Пока гейт
красный, опиниативные проходы не запускаются. Гейт также выдаёт находки
класса «отсутствующая верификация» — непокрытые изменённые строки,
конкурентность без параллельного теста, флаки.
2. **Сверка со спекой двунаправленная**, причём code → spec важнее: ищем
поведение, которого дельта не заказывала, и классифицируем — дописать спеку
или починить код. Проходу явно разрешено сомневаться в самом требовании.
3. **Generative-проходы** как отдельный слой: рубрика до чтения кода,
независимая реализация без подглядывания, заземление идиоматичности на
stdlib и поимённые положения гайдов, негативное пространство. Только они
достают то, чего нет ни в одном списке.
4. **Обязательный триаж** с потолком 7 находок и разметкой `инлайн`/`развилка`.
Отчёт читает оркестратор и молча реализует прочитанное, поэтому потолок
защищает кодовую базу от незаказанных правок, а не внимание человека.
5. **Храповик**: находка → конвенция → правило линтера → **удаление из прозы и
промптов**. Третий шаг обязателен; в конвенциях остаётся только то, что
правилом не выражается.
6. **Измеримость**: журнал проскочивших дефектов и калибровка инъекцией с
вердиктами `keep`/`retune`/`drop`. Проход, не находящий дефект своего класса
в 2 из 3 прогонов после двух правок промпта, удаляется, а не правится дальше.
## Последствия
- `+` У ревью появился объективный оракул там, где он вообще возможен, и явная
граница между «проверено», «не проверялось» и «недоступно в принципе».
- `+` Неявный слой (форма решения, лишние абстракции, отсутствующее) стал
предметом отдельных проходов, а не побочным эффектом чтения диффа.
- `+` Механизируемое ушло в `.golangci.yml` и `internal/archrules`: конвенции и
промпты разгружены, правило проверяется бесплатно и всегда.
- `-` Профиль `deep` заметно дороже прежнего ревью по токенам и времени; отсюда
профили и правило выбора по факту изменения.
- `-` Generative-проходы шумят: без триажа они делают хуже, чем ничего.
Триаж стал обязательным элементом, а не опцией.
- `-` Набор проходов теперь нужно **измерять**, иначе он вырождается в театр.
Журнал заполняется по горячим следам, ретроспективные записи бесполезны.
- Как следствие: калибровку проходов надо провести на реальных пробах —
процедура заведена, первые прогоны за владельцем.
@@ -0,0 +1,73 @@
# Образ собирается локально и едет на сервер через docker save/load
- **Дата:** 2026-07-24
- **Источник:** архивного `design.md` нет — решение деплойное, change'ем jellybit
не велось
## Контекст
Заменяет [ADR-2026-06-13-docker-deploy](ADR-2026-06-13-docker-deploy.md).
Docker как единица деплоя и распределение ответственности (`Dockerfile`
— упаковка — живёт в jellybit; оркестрация — в umbar) остаются в силе.
Пересматривается только **как** образ попадает на сервер.
Прежняя схема собирала статический бинарь на control-хосте, копировала
на сервер бинарь + `Dockerfile` и делала `docker build` **на месте**.
У этого два неудобства. Во-первых, на сервере остаётся шаг сборки: пусть
дешёвый на Intel N150, но результат косвенно завязан на состояние сервера
(его docker, его кэш), а не только на исходник. Во-вторых, схема
одноразовая — она была вписана в `playbook-jellybit.yml` и не
переиспользовалась. Появление второго такого же приложения (trackers)
потребовало вынести доставку образа в общую ansible-роль `app_image` и
заодно унифицировать контракт с приложением.
## Рассмотренные варианты
- **Оставить сборку на сервере** — прежнее решение. Держит на сервере шаг
`docker build` и делает результат зависимым от состояния сервера;
переиспользовать без копипасты плейбука неудобно.
- **Реестр (CI пушит образ, сервер тянет)** — каноничнее, но в домашней
лаборатории это лишний реестр и пайплайн ради одного узла. Отвергнуто по
той же причине, что и в исходной ADR.
- **Собрать полный образ локально и доставить его `docker save`/`load`** —
выбрано: сборка целиком на control-хосте, сервер — только получатель, и
реестр не нужен.
## Решение
Доставку образа ведёт переиспользуемая роль `app_image` в umbar. Схема:
- **Контракт с приложением.** Приложение реализует команду `task image`:
получает `BUILD_ID` из окружения и собирает **полный** образ
`<app>:$BUILD_ID`. Без `BUILD_ID` собирается `<app>:dev` — обратная
совместимость для локальной работы. Приложение полностью владеет тем,
как собирается его образ (`Dockerfile` — в репозитории приложения).
- **Сборка локальна.** Роль генерит случайный `BUILD_ID` и гоняет с ним
`task image` на control-хосте → образ `<app>:$BUILD_ID`. На сервере
Go-тулчейн и `docker build` больше не нужны.
- **Тег = BUILD_ID.** Deploy-тег — тот самый случайный `BUILD_ID`.
Дедупликации по содержимому нет: тег нов на каждый прогон. Content-адресацию
(тег из хеша слоёв/конфига образа) рассматривали и отвергли — на сценариях
ручного нечастого деплоя выгода от неё не окупала сложности.
- **Доставка без реестра.** `docker save` → copy tar → `docker load`.
Каждый деплой везёт образ и пересоздаёт контейнер.
- **Уборка.** Старые образы на сервере (тег каждого прошлого деплоя)
подчищает `docker image prune -af` по крону (`playbook-system.yml`).
## Последствия
- `+` Сервер — только получатель образа: без Go-тулчейна и без шага
`docker build`.
- `+` Сборка целиком на control-хосте — воспроизводимее; состояние
сервера на результат не влияет.
- `+` Реестр по-прежнему не нужен — доставка дешёвая (`save`/`load` tar).
- `+` Механизм общий (роль `app_image`), а не вписан в один плейбук —
им же доставляется trackers.
- `-` Дедупликации нет: тег = случайный `BUILD_ID`, поэтому каждый деплой
везёт образ и пересоздаёт контейнер, даже если ничего не менялось. Для
ручного нечастого деплоя это осознанный размен — простота роли важнее
экономии одного рестарта.
- `-` `save`/`load` везёт весь образ (базовый слой + бинарь), а не только
бинарь, как в прежней схеме. Для `distroless/static` это единицы
мегабайт — дёшево.
@@ -0,0 +1,97 @@
# Конвейер ревью и пайплайн задачи переезжают в плагины
- **Дата:** 2026-08-04
- **Источник:** `DECISIONS.md` §4 «Границы плагинов» (2026-08-03) и §7
«Раскладка скиллов» репозитория `av-dev-skills`
(`https://git.vakhrushev.me/av/dev-skills.git`). Архивного `design.md` у этого
решения нет: оно принималось в репозитории плагинов, а не change'ем jellybit —
отсюда и отступление от правила «ADR это промоут поверх архивного design.md».
**Не заменяет [ADR-2026-07-23](ADR-2026-07-23-review-pipeline-generative.md).**
Форма конвейера, принятая там (детерминированный гейт первым, двунаправленная
сверка со спекой, слой generative-проходов, обязательный триаж с потолком 7,
храповик «находка → правило → удаление», журнал и калибровка), остаётся в силе
целиком. Пересматривается только **где конвейер живёт и кому принадлежит**, плюс
одно частное следствие — судьба прохода про идиоматичность.
## Решение
Конвейер ревью, пайплайн задачи и пайплайн партии задач перестают быть
артефактами jellybit и переезжают в плагины маркетплейса `av-dev-skills`:
- **`av-dev-pipeline`** — исполнение: SDD-цикл (`task-pipeline`, `task-batch`) и
конвейер ревью с девятью агентами;
- **`av-dev-pm`** — управление продуктом: канон документов (`canon`, `docs`),
задачи и цели (`tasks`), ритуал спринта (`session`);
- **`av-dev-git`** — стиль коммитов.
Из репозитория удалены 11 агентов `jellybit-review-*` (1159 строк) и три скилла
с их справочниками (932 строки). Проектная специфика, которая раньше была
вшита в промпты агентов, теперь приходит из документов канона — прежде всего из
[docs/review.md](../review.md): типовые узлы, типовые ложноположительные,
вопросы к проходам, триггеры профиля, недоступное проверке.
## Почему
Причина словами владельца, из источника:
> «пайплайн можно и переиспользовать в других проектах с более простым подходом
> к управлению»
и разбор, который её подтверждает:
> Пайплайн зависит от **файлов канона и от OpenSpec, а не от плагина**
> `av-dev-pm`. В чужом проекте нужных файлов нет — включается поразрядная
> деградация, и это штатный режим, а не поломка.
Второе: копия конвейера жила не только здесь. Те же скиллы и агенты лежали в
healthlog, и **копии успели разойтись** — правка, сделанная в одном проекте, во
второй не приезжала никогда. Скилл, который правят в двух местах, работает в том
из них, куда заглянули последним; это ровно та болезнь, которую канон лечит
правилом единственного дома, и на процессных артефактах она проявилась раньше,
чем на документах.
Третье: разделение плагинов по симметрии «раскладка и содержимое» —
`canon`/`docs` для документов, `tasks`/`session` для задач — сняло конфликт
владения `docs/tasks/`, из-за которого прежний `av-dev-backlog` и пайплайн
претендовали на один каталог.
## Рассмотренные варианты
- **Оставить копию в проекте, синхронизировать руками.** Отвергнуто фактом:
именно это и делалось, и копии разошлись. Ручная синхронизация двух проектов
не имеет ни оракула, ни момента, когда её обязаны выполнить.
- **Один общий плагин на всё.** Отвергнуто: пайплайн переиспользуем в проекте,
который канон av-dev не ведёт, а канон полезен там, где нет OpenSpec. Один
плагин связал бы их жёстче, чем они связаны по существу.
- **Держать в проекте только агентов, а скиллы вынести.** Отвергнуто: агенты и
есть содержание конвейера; вынести оболочку, оставив начинку, значит получить
ту же расходящуюся копию, только менее заметную.
## Последствия
- `+` Правка конвейера делается один раз и приезжает во все проекты; расхождение
копий структурно невозможно.
- `+` Из промптов агентов ушла проектная специфика — она читается из документов
канона, поэтому обновляется вместе с проектом, а не отдельной правкой девяти
промптов.
- `+` `-2091` строка процессных артефактов в репозитории; репозиторий описывает
jellybit, а не то, как над ним работают.
- `` **Проход `idiom` упразднён.** Поимённая сверка с положениями Effective Go,
Go Code Review Comments и стайлгайдов Uber/Google не задаётся теперь ни одним
проходом; способные части переселены (эксперимент против поведения библиотеки
и драйвера — в `ops`, «не изобретаем ли то, что уже есть в библиотеке» — в
`architecture`). **Различение «идиоматично против распространено» не
спрашивает никто.** Класс обратимый — портит форму кода, не данные. Записано в
[docs/review.md](../review.md) → «Перестали проверять сознательно»; пересмотр —
задача `quality-review-agents`.
- `` Версия конвейера больше не зафиксирована коммитом проекта: обновление
плагина меняет ревью задним числом, и старый прогон не воспроизводится.
Отчёт триажа в `openspec/changes/<id>/review/` остаётся единственным
артефактом того, что реально проверялось.
- `` Проектная специфика теперь **обязана** быть в `docs/review.md`. Пустой или
устаревший раздел там больше не компенсируется вшитой в промпт конкретикой —
проход просто спросит общее вместо частного, и это будет незаметно.
- `` Появилась внешняя зависимость сборки процесса: без подключённого
маркетплейса `av-dev-skills` пайплайн и ревью недоступны. Шаг `canon` в
`task gate` по той же причине краснеет внятно, когда `docs.py` не найден.
@@ -0,0 +1,80 @@
# Спека следует за кодом, когда гарантия недостижима, а окно узкое
- **Дата:** 2026-08-06
- **Источник:**
[openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/design.md](../../openspec/changes/archive/2026-08-06-catched-source-type-reread-wording/design.md)
## Решение
Требование `download-tracking` о re-read `source_type` приведено **к коду**, а
не наоборот: перечитывать под блокировкой переходов **после тик-снимка**, а
остаточное окно (апгрейд magnet → `.torrent`, легший в вызов namer'а) названо в
спеке известным ограничением с ценой и маршрутом восстановления. Код не тронут.
Общее правило, которое отсюда следует для проекта: когда спека и код разошлись,
**двигается тот, чья формулировка сильнее рационали**. Требование, обещавшее
больше, чем нужно ради его собственной причины, чинится текстом; недостающая
гарантия чинится кодом.
## Почему
Формулировка была сильнее своей же рационали:
> Рациональ исходного требования — «не полагаться на снимок, снятый ранее вне
> блокировки» — выполнен первым re-read под замком. Формулировка «непосредственно
> перед добавлением» была сильнее рационали и кодом не достигается: между
> re-read и `qbt.Add` стоит namer, вынесенный из-под блокировки намеренно
> (требование «Медленные вызовы SHALL выполняться вне блокировки»).
Цена починки кодом оказалась несоразмерной ущербу:
> Вариант A (пересобирать `addReq` из `before` под замком) отклонён: это не
> однострочник — `sourceAddParts` читает байты `.torrent` и держать его под
> блокировкой нельзя, а при апгрейде корректен был бы и повторный вызов namer'а
> (подсказка имени берётся из другого источника).
Главное же — **молчащая ложная гарантия дороже названного ограничения**. Пока
спека утверждала недостижимое, дефект был невидим ровно потому, что нормативный
дом поведения его отрицал; аудит capability находил его заново.
## Рассмотренные варианты
- **A — починить код** (пересобирать запрос на добавление из свежей записи под
блокировкой). Отвергнут по цене: чтение блоба `.torrent` под замком
недопустимо, корректная версия тянет повторный вызов namer'а. Отвергнут
**отложенно, а не окончательно**: спека поэтому не запрещает его нормативно
(см. `design.md` D6).
- **B — привести спеку к коду** (принято). Наблюдаемое поведение прежнее,
меняется заявленное.
- **C — оставить как есть.** Отвергнут: расхождение спека↔код воспроизводится
каждым аудитом, а читатель спеки считает окно закрытым.
## Последствия
- `+` Нормативный дом поведения перестал утверждать недостижимое; ограничение
видно и имеет названную цену вместо молчания.
- `+` Парная ложная гарантия снята и в `ingest` («воркер добавит раздачу
файлом») — иначе она бы просто переехала в соседнюю capability и всплыла
следующим аудитом.
- `+` Заодно назван исход ветки «сохранённые байты `.torrent` недоступны», не
заказанной до этого ни одним сценарием.
- `` Цена окна выше, чем считала постановка задачи: не «подождать и нажать
`Retry`», а ожидание `magnet_timeout` (дефолт `24h`) **плюс ручной шаг**
убрать зависшую раздачу из qBittorrent. `Retry` сам не добивает: `metaDL`
считается живым и здоровым торрентом, и `Retry` к нему перецепляется без
повторного `add` (`design.md` D2). На этой пересмотренной цене вопрос
«чинить ли окно кодом» открыт заново.
- `` Новая нормативная ветка (недоступные байты `.torrent`) держится на чтении
кода: теста-оракула у неё нет, её регрессия зелёный гейт не покрасит.
- `` Прецедент «спека следует за кодом» опасен буквальным применением. Он
оправдан **только** когда формулировка сильнее рационали и ущерб от разрыва
назван; «код так делает, значит так и запишем» этой записью не
санкционируется.
## Триггер пересмотра
Записан отдельно, чтобы не гонять круг заново:
> Окно возвращается в работу вариантом A, когда апгрейд в вызове namer'а
> случится в эксплуатации хотя бы раз — признак в логах — либо когда стоимость
> ручного шага станет заметной. До того — принято и описано.
@@ -0,0 +1,66 @@
# Локаль TVDB читается из ответа поиска, а не передаётся в запрос
- **Дата:** 2026-08-07
- **Источник:** [openspec/changes/archive/2026-08-07-tvdb-title-locale/design.md](../../openspec/changes/archive/2026-08-07-tvdb-title-locale/design.md),
решение 1 и решение 1a
## Контекст
Глобальная настройка `[general].language` правит промпт LLM и клиент TMDB
([ADR решения 2](../../openspec/changes/archive/2026-07-24-content-language-switch/design.md)),
но до клиента TVDB не доезжала. TVDB отдавал primary name — название на языке
оригинала, — и оно попадало в карточку ревью и в имя папки Jellyfin как есть.
Очевидный подход, записанный прямо в постановке задачи и в её критерии приёмки:
добавить параметр языка в запрос `/search`, как это сделано для TMDB. От него
отказались.
## Решение
**Параметр языка в запрос поиска TVDB не передаётся. Локаль применяется только
при разборе ответа: `Candidate.Title` берётся из блока переводов, `OriginalTitle`
— из primary name.**
Цитата решения 1 архивного `design.md`:
> По [swagger TVDB v4, версия 4.7.10] у `/search` есть параметр `language` с
> описанием «Restrict results to a specific primary language. Should include the
> 3 character language code» — это **фильтр выдачи**, а не селектор перевода.
> Передача `language=rus` отсекла бы записи, основной язык которых не русский,
> то есть ровно наблюдаемый случай (`Ne Zha`, основной язык `zho`). Сужение
> выдачи — это изменение входа гейта матча, а задача такое явно запретила.
Тем самым два провайдера намеренно устроены по-разному: у TMDB локаль едет в
запрос, у TVDB читается из ответа. Асимметрия оставлена в клиентах, а не поднята
в общий тип: у TMDB карты названий в ответе нет вовсе, и общий тип пришлось бы
заполнять единственным ключом (решение 1a, форма B).
## Рассмотренные варианты
- **Слать `language` и мириться с сужением выдачи.** Ломает основной сценарий:
иноязычные записи, ради которых задача заводилась, пропадут из поиска.
- **Отдельный запрос `/movies/{id}/translations/{lang}` на каждого кандидата.**
Цена в лимитах ключа не окупает косметическое поле.
- **Заголовок `Accept-Language`.** Для v4 не документирован — была бы догадка.
- **`Candidate` несёт карту названий, выбор делает потребитель** (форма B). Язык
у потребителя уже есть, плюмбинг не нужен, но у TMDB карты в ответе нет —
внутри одного доменного типа завелись бы две формы.
- **TVDB не локализуется вовсе, заполняется только `OriginalTitle`** (форма C).
Тогда наблюдаемый случай чинится только при включённом TMDB, давшем матч, —
поведение молча зависело бы от набора включённых провайдеров.
## Последствия
- Критерий приёмки задачи «запрос поиска содержит параметр языка» выполнен быть
не может и отменён этим решением. Расхождение вынесено вопросом человеку —
разведка [tvdb-search-response-live-check](../../tasks/items/tvdb-search-response-live-check.md).
- **Решение опирается на документацию, а не на замер.** Семантика параметра и
форма блока переводов живым API не подтверждены —
[research/tvdb-search-translations.md](../research/tvdb-search-translations.md).
Если ручной прогон под ключом покажет иное, эта запись пересматривается новой,
а не правится.
- Заполнение `OriginalTitle` дало кандидату TVDB две оси сравнения вместо одной.
Логика гейта не менялась, но его вход изменился в обе стороны: запись, которую
отсекал иероглифический primary name, теперь может пройти по переводу, а две
разные записи могут совпасть с планом разными названиями и увести задачу в
review. Инвариант «авто-раскладка только при подтверждённом матче» не двигается.
@@ -0,0 +1,52 @@
# Отметка «последняя копия» на подтверждении удаления выводится из состояния, а не из файловой системы
- **Дата:** 2026-08-10
- **Источник:** openspec/changes/archive/2026-08-10-bulk-delete-page/design.md
## Решение
Экран подтверждения группового удаления помечает загрузку как последнюю копию
данных по её **состоянию** (`orphaned`), не спрашивая файловую систему. Случай,
когда байты источника исчезли с диска, а раздача осталась в списке qBittorrent,
такой отметки не получает — и это записано границей в спеке `web-ui`, а не
оставлено умолчанием.
## Почему
Гард последней копии в `Delete` выключен сознательно (инвариант «источник
неприкосновенен», исключение 1), поэтому осведомлённость человека — единственный
оставшийся предохранитель. Отсюда решение D2 источника:
> Признак берётся из состояния (`orphaned` по определению значит «источник
> пропал, цель — последняя копия»), а не обходом файловой системы.
Враждебный проход ревью построил путь, где это неверно: сверка берёт присутствие
источника из ответа `torrents/info`, а не с диска, поэтому задача с пропавшими
байтами остаётся `done` сколько угодно долго и отметки не получает. Дыра
признана и оставлена открытой по решению человека: поштучное удаление такой
отметки не несёт **вовсе**, то есть групповой путь не ухудшил положение, а
улучшил его не до конца. Закрывать её обходом файловой системы на экране
подтверждения значит завести чтение диска в транспорте ради предупреждения,
которое и сегодня лучше прежнего.
## Рассмотренные варианты
- **Спрашивать файловую систему на подтверждении** (`nlink` по живым ссылкам
последнего батча) — отметка стала бы правдой, но транспорт начал бы ходить в
файловую систему ради показа, а пачка ограничена двадцатью строками только
сегодня.
- **Вернуть гард последней копии в `Delete`** — отменяет само назначение
команды: она затем и существует, чтобы снять последнюю копию осознанно.
- **Убрать отметку совсем** — честно, но теряет полезный сигнал про пропавший
источник, который в подавляющем большинстве случаев и есть последняя копия.
## Последствия
- `+` Подтверждение предупреждает о последней копии там, где раньше не
предупреждало ничто; признак берётся из домена, второго перечня состояний не
заводится.
- `+` Транспорт не ходит в файловую систему ради показа.
- `` Случай «`done` с пропавшими байтами источника» отметки не получает. Дыра
названа в спеке прямо, чтобы отметка не читалась как гарантия.
- `` Пока сверка берёт присутствие источника из списка раздач, а не с диска,
закрыть дыру нельзя ни на одном экране.
@@ -0,0 +1,56 @@
# Наблюдаемость поверхности не выводится из терминальности задачи
- **Дата:** 2026-08-10
- **Источник:** openspec/changes/archive/2026-08-10-card-live-refresh/design.md
## Решение
Веб-UI обновляет себя, пока задача **наблюдаема** — то есть её состояние ещё
может измениться без участия человека, — а не пока она нетерминальна.
Предикат `store.State.IsObservable()` живёт в домене рядом с `IsTerminal()` и
даёт: все нетерминальные плюс `failed`, `target_missing`, `orphaned`. Замолкают
`done`, `cancelled`, `reverted`, `deleted`.
## Почему
Очевидный предикат — «обновляемся, пока задача не терминальна» — оказался
неверным, и это выяснилось на ревью дизайна, до кода. Цитата из источника:
> Терминальность в проекте значит «не активна», а не «навсегда»: фоновая сверка
> двигает часть терминальных сама — `ListRecoverable` возвращает в поток
> `failed`/`stuck` с кодами `magnet_timeout` и `stalled`, а `desyncStates`
> переоценивает `done`, `target_missing` и `orphaned`. Карточка, застывшая по
> `IsTerminal`, показывала бы «Ошибка» у задачи, которая уже качается, — ровно
> тот дефект, ради которого затеян change.
`done` в перечень наблюдаемых не вошёл, и это отдельное решение с ценой:
> Переход `done → target_missing`/`orphaned` означает, что файлы удалили руками
> мимо сервиса, — событие редкое, а карточек `done` в списке больше всех.
> Платить за редкий случай постоянным фоновым запросом на каждую разложенную
> задачу дороже, чем показать её новое состояние при следующем заходе.
## Рассмотренные варианты
- **Наблюдать только нетерминальные** (как задумывалось изначально) — проще
всего и не заводит второго предиката. Отвергнут: задача, оживлённая сверкой из
`failed`, висела бы на экране с надписью «Ошибка» до перезагрузки, причём
соседние карточки при этом обновлялись бы — застывшая читалась бы как
достоверная.
- **Наблюдать всё, терминальные — редким тиком** — снимает вопрос целиком.
Отвергнут: список из сотни разложенных задач слал бы пустые запросы вечно, а
критерий приёмки «завершённая карточка себя не опрашивает» пришлось бы
отменить.
## Последствия
- `+` смена состояния становится видимой независимо от того, кто её сделал:
воркер, веб-UI, Telegram или фоновая сверка.
- `+` условие обновления выражено одним доменным предикатом; второго перечня
состояний в транспорте нет, и завести его нельзя не заметив.
- `` в домене стало два перечня состояний вместо одного, и второй выведен из
поведения воркера (`desyncStates`, `ListRecoverable`) вручную. Расширение
сверки новым состоянием молча вернёт застывшую карточку — связки, которая бы
это ловила, нет.
- `` карточка `failed`, `target_missing` или `orphaned` опрашивает сервер, пока
открыта вкладка: эти состояния живут долго и копятся (срока хранения нет).
@@ -0,0 +1,77 @@
# Причина, по которой человек не видит плана, считается на показе, а не читается из состояния
- **Дата:** 2026-08-10
- **Источник:**
[openspec/changes/archive/2026-08-10-long-title-to-review/design.md](../../openspec/changes/archive/2026-08-10-long-title-to-review/design.md),
Решение 6 и раздел `Risks / Trade-offs`; отчёт триажа того же change,
находка 1
## Контекст
Проверка длины целевого имени встала в `layout.BuildLinks` — туда же, где
собираются оба предпросмотра экрана ревью. Это дало даром совпадение показанного
с применённым, но и вторую половину: непомещающееся имя обнуляет предпросмотр, а
без предпросмотра экран прячет команду «Применить».
Первым решением панель действий брала текст из `error_msg` — причины, записанной
при последнем переходе. Ревью показало, что на самом частом входе этого поля
нет вовсе: задача, пришедшая в `review` из-за отсутствия матча, попадает туда с
пустой причиной и до раскладки не доходит. Замер триажа на двух деревьях:
```
до изменения: предпросмотр строится, «Применить» доступна,
применение доводит до failed с текстом ядра
после: предпросмотр пуст, команды нет, причины нет —
экран печатает «Подтверди источник», хотя источник ни при чём
```
То есть изменение, чья цель — «человек узнаёт причину», на этом входе
диагностируемость ухудшало.
## Решение
**Причина отказа считается в момент показа и отдаётся транспорту значением
(`worker.ReviewData.PreviewError`); записанная в состоянии используется только
когда посчитанной нет.**
Цитата из `design.md`, Решение 6:
> Оба предпросмотра ревью (карточка и строка источника) строят пути тем же
> `BuildLinks`, поэтому вердикт на показе и вердикт на применении совпадают по
> устройству, а не по договорённости.
Отсюда следует и обратное: раз вердикт считается на показе, там же считается и
его причина. Посчитанная предпочитается записанной по двум причинам сразу:
записанной может не быть вовсе, а после смены источника она уже про другой план — команды,
меняющие эффективный источник, поля ошибки не чистят.
**Чтение при этом состояние не двигает.** Построение предпросмотра остаётся без
побочных эффектов; причина уходит наружу возвращаемым значением.
Это второй случай одного класса за день. Первый —
[ADR-2026-08-10-sanitize-at-every-entry](ADR-2026-08-10-sanitize-at-every-entry.md):
гарантия, поставленная на запись, не покрывает то, что записано раньше. Здесь она
не покрывает то, что не записано вовсе.
## Рассмотренные варианты
- **Записывать причину в состояние при построении предпросмотра.** Отвергнуто:
чтение начало бы двигать состояние. Запрет уже стоял в коде отдельным
комментарием — предпросмотр не переводит задачу в `review` при рассинхроне
папок, — и заводить исключение ради текста на экране значило бы снять правило.
- **Оставить как есть, записав остаток сценарием спеки.** Отвергнуто на
чекпоинте: регресс диагностируемости дошёл бы до боевого окружения на самом
частом входе.
- **Печатать причину только в баннере состояния, панель не трогать.** Отвергнуто:
баннер показывает записанное и на этом входе пуст ровно так же.
## Цена
Причина живёт в двух местах — записанная в состоянии и посчитанная на показе, — и
порядок между ними держится на ревью, а не на типе. Взамен экран ревью объясняет
отсутствие команды всегда, а не только когда причину успели записать, и
объяснение относится к текущему плану, а не к прошлому.
Побочно: тот же текст может оказаться и в баннере, и в панели, когда записанная
причина совпала с посчитанной. Дубль признан приемлемым — он честен, а
код, который его снимал бы, дороже.
@@ -0,0 +1,82 @@
# Значение метабазы чистится на каждой точке входа в план, а три санитайзера не сводятся в один
- **Дата:** 2026-08-10
- **Источник:**
[openspec/changes/archive/2026-08-10-metadata-title-sanitize/design.md](../../openspec/changes/archive/2026-08-10-metadata-title-sanitize/design.md),
разделы `Decisions` (Решения 1, 1a, 3) и `Non-Goals`
## Контекст
Название, приходящее из TMDB/TVDB/TVMaze, попадает в имя каталога библиотеки
Jellyfin. Выход LLM мы чистим и считаем недоверенным; название из метабазы того
же обращения не получало, хотя приходит так же — из-за периметра. Наблюдаемый
исход: каталог из невидимых символов выглядит пустым, кириллическая буква внутри
латинского слова даёт вторую папку, неотличимую от первой, и авто-раскладка это
пропускала.
Разбор показал, что точка входа не одна. Их четыре, и каждая ведёт в имя
каталога: сборка подтверждённого матча, копия кандидата, уходящая на экран
ревью и в хранилище, набор закреплённых значений выбранного человеком
источника и **чтение** уже закреплённого значения.
## Решение
**Чистка стоит на каждой из четырёх точек, а не в одной «правильной».**
Цитата из `design.md`, Решение 1a:
> Закрываются обе одной и той же чисткой, но в трёх местах — по одному на
> каждую точку, где значение метабазы входит в домен.
Плюс четвёртая, добавленная по находке эксплуатационного прохода: чистка **на
чтении** закреплённого значения. Гарантия чистоты не может держаться на времени записи строки —
кандидаты и закреплённые значения, сохранённые прежними версиями, обходят её,
а обычное
«Применить» ничего не перезаписывает. Санитайзинг идемпотентен, поэтому лишние
точки на уже чистом значении не делают ничего; это же свойство сделано
нормативным и покрыто тестом.
Отдельно: **гейт подтверждения матча чистка не двигает.** Сравнение кандидата с
планом идёт по значениям провайдера, чистится только копия, уходящая дальше.
Причина в том, что `normalize` и санитайзинг не эквивалентны: невидимый символ
внутри слова `normalize` превращает в пробел, а санитайзинг удаляет — чистка до
сравнения превратила бы часть нынешних «в review» в «авто».
## Рассмотренные варианты
- **Свести три санитайзера проекта в один.** Отвергнуто: у них разный предмет —
`recognize.SanitizeTitle` чистит значение, `layout.sanitizeComponent`
компонент пути под требования файловой системы, `naming.sanitize`
отображаемый ярлык. Свёртка гомоглифов — визуально неотличимых букв из разных алфавитов — внутри
`sanitizeComponent` сломала бы
правило сходимости базы папки: она гоняется и по имени, прочитанному с диска.
- **Закрыть только авто-путь, ручной отдать отдельной задаче.** Отвергнуто на
чекпоинте: спека `metadata-match` сама называет ручной выбор **основным**
путём подтверждения матча — починка коснулась бы менее употребимой половины,
а спека утверждала бы свойство, которого нет.
- **Чистить в клиентах метабаз.** Отвергнуто: пришлось бы повторять в трёх
клиентах и в каждом следующем, а проверка «в плане нет грязных полей»
перестала бы читаться в одном месте.
- **Разовая правка данных вместо чистки на чтении.** Отвергнута как более
дорогая и не закрывающая следующего читателя.
- **Полная нормализация Unicode** (NFC/NFKC плюс полная таблица визуально
совпадающих символов Unicode) — Non-Goal. Цель — предсказуемое и сверяемое значение, а не исчерпывающая защита
от визуального совпадения; курируемая кирилло-латинская таблица закрывает
реальный случай.
## Что осталось нерешённым намеренно
**Каталог с невидимым символом, уже созданный в библиотеке, кодом не лечится.**
Правило сходимости базы папки наследует имя от живой папки-якоря, и очистка
извлечённой базы напечатала бы рядом вторую, чистую папку — то есть ровно тот
исход с двумя каталогами, против которого затевалось изменение. Лечение —
переименовать папку руками, после чего сходимость подхватит новое имя.
Изменение закрывает появление новых таких каталогов, а не существующие.
## Цена
Точек чистки четыре вместо одной, и правило «значение метабазы чистится на
входе в домен» держится на ревью, а не на линтере. Взамен свойство «показанное
на экране совпадает с тем, что ляжет на диск» держится устройством кода: чистка
стоит в `sourcePins` — общем доме набора закреплённых значений, через который
идут и предпросмотр, и закрепление.
+41 -45
View File
@@ -1,62 +1,58 @@
# Architecture Decision Records (ADR) # Журнал решений
Журнал значимых архитектурных решений по jellybit. Одна запись — одно Одна запись — одно решение. **ADR это промоут поверх архивного `design.md`**,
решение. ADR пишем **постфактум**, когда решение принято и зафиксировано а не второе сочинение: запись цитирует решение и ссылается на
в коде/проекте: идеи и неподтверждённые планы живут в `docs/drafts`, а не `openspec/changes/archive/<id>/design.md`.
в ADR. Записи **неизменяемы**: передумали → не правим старую, заводим
новую и помечаем старую.
Главная ценность записи — сохранить **почему**: намерение и причинность. Главная ценность записи — сохранить **почему**: намерение и причинность. Это
Это важнее аккуратности оформления и полноты остальных секций. важнее аккуратности оформления и полноты остальных секций.
Формат и процесс унаследованы от соседнего проекта umbar. ## Когда заводить
## Когда заводить ADR Верно одно из трёх:
- Выбор технологии или инструмента. <!-- копия: adr-когда-заводить из av-dev-docs/skills/canon/references/canon.md -->
- Структурные решения (хранилище, организация компонентов, протоколы). - **дорогой откат** — переделка стоит дороже переписывания одного файла;
- Решения с долгосрочными последствиями или дорогим откатом. - **намеренный отказ** от очевидного подхода;
- **Намеренный отказ** от очевидного подхода — чтобы потом не - **пересмотр прежнего решения** — тогда у старой записи обязателен статус
переоткрывать «а почему мы не сделали X». «заменено на».
<!-- /копия: adr-когда-заводить -->
Не заводить для рутины (бамп версии зависимости, добавление эндпоинта по Не заводить для рутины и для того, что видно из кода и `git log`.
накатанной схеме) и того, что и так видно из кода и git.
## Соглашения ## Соглашения
- **Имя файла = идентификатор:** `ADR-ГГГГ-ММ-ДД-kebab-slug.md`. - Имя файла `ADR-ГГГГ-ММ-ДД-slug.md`, слаг английский, дата — когда решение
Идентификатор — имя без `.md`. Slug — латиницей. реально принято. Идентификатор записи — имя файла без `.md`; несколько
- **Дата** — когда решение реально принято. записей за один день различаются слагом.
- Несколько ADR за один день различаются по slug. - **Заголовок в файле** — `# Человеческий заголовок`, без даты и id: они в
- **Заголовок в файле:** `# Человеческий заголовок` (без даты и ID — они имени файла и в поле меты.
в имени файла и в строке «Дата»). - Секция «Рассмотренные варианты» **опциональна**: оставляй, только если
- Секция **«Рассмотренные варианты» — опциональна**: оставляй её, только альтернативы реально рассматривались.
если альтернативы реально рассматривались. - Записи неизменяемы: передумали — новая запись, старой ставится статус.
- Шаблон новой записи — [`template.md`](template.md). - Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
источником, а не абзацем в теле.
- Замена: в новой записи — строка «Заменяет ADR-…», в старой — поле статуса,
тело не трогаем (это часть истории), в таблице ниже правится статус.
## Статусы ## Записи
Активная запись статуса **не имеет**. Статус появляется, только когда
запись теряет силу, и значений всего два:
- `заменено на ADR-ГГГГ-ММ-ДД-slug` — решение пересмотрено новой ADR.
- `устарело` — решение потеряло смысл и замены нет.
## Замена и устаревание
1. Заводим новую ADR; в её «Контексте» — строка
«Заменяет ADR-ГГГГ-ММ-ДД-slug».
2. В старой ADR добавляем строку `- Статус: заменено на ADR-…` сразу под
датой. Тело не трогаем — это часть истории.
3. Обновляем статус старой записи в индексе ниже.
## Список записей
Новые сверху. Новые сверху.
| Дата | Запись | Статус | | Дата | Запись | Статус |
| ---------- | ---------------------------------------------------------------- | ------ | | --- | --- | --- |
| 2026-08-10 | [Отметка «последняя копия» на подтверждении удаления выводится из состояния, а не из файловой системы](ADR-2026-08-10-last-copy-warning-from-state.md) | — |
| 2026-08-10 | [Наблюдаемость поверхности не выводится из терминальности задачи](ADR-2026-08-10-observability-is-not-terminality.md) | — |
| 2026-08-10 | [Причина, по которой человек не видит плана, считается на показе, а не читается из состояния](ADR-2026-08-10-reason-computed-on-read.md) | — |
| 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — |
| 2026-08-07 | [Локаль TVDB читается из ответа поиска, а не передаётся в запрос](ADR-2026-08-07-tvdb-locale-reads-response.md) | — |
| 2026-08-06 | [Спека следует за кодом, когда гарантия недостижима, а окно узкое](ADR-2026-08-06-spec-follows-code-on-narrow-window.md) | — |
| 2026-08-04 | [Конвейер ревью и пайплайн задачи переезжают в плагины](ADR-2026-08-04-review-pipeline-to-plugin.md) | — |
| 2026-07-24 | [Локальная сборка образа + доставка docker save/load](ADR-2026-07-24-local-image-build.md) | — |
| 2026-07-23 | [Конвейер ревью: гейт, generative-проходы и триаж](ADR-2026-07-23-review-pipeline-generative.md) | — |
| 2026-07-02 | [Отдельную сущность «тайтл» не вводим](ADR-2026-07-02-no-title-entity.md) | — |
| 2026-06-13 | [Авто-раскладка только при матче в метабазе](ADR-2026-06-13-auto-link-requires-db-match.md) | — | | 2026-06-13 | [Авто-раскладка только при матче в метабазе](ADR-2026-06-13-auto-link-requires-db-match.md) | — |
| 2026-06-13 | [Docker как единица деплоя](ADR-2026-06-13-docker-deploy.md) | — | | 2026-06-13 | [Docker как единица деплоя](ADR-2026-06-13-docker-deploy.md) | заменено на ADR-2026-07-24-local-image-build |
| 2026-06-13 | [Хардлинки вместо копирования и симлинков](ADR-2026-06-13-hardlinks.md) | — | | 2026-06-13 | [Хардлинки вместо копирования и симлинков](ADR-2026-06-13-hardlinks.md) | — |
| 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.md) | — | | 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.md) | — |
+20 -24
View File
@@ -1,36 +1,32 @@
# Краткий заголовок решения # Краткий заголовок решения
- Дата: ГГГГ-ММ-ДД - **Дата:** ГГГГ-ММ-ДД
<!-- Строку статуса добавляют позже, только если запись потеряла силу: - **Источник:** openspec/changes/archive/<id>/design.md
- Статус: заменено на ADR-ГГГГ-ММ-ДД-slug
- Статус: устарело
У активной записи строки статуса нет. -->
## Контекст <!-- Статус ставится тем же полем и только при пересмотре:
- **Статус:** заменено на ADR-ГГГГ-ММ-ДД-slug
- **Статус:** устарело
У активной записи поля нет. -->
Что вынудило принять решение: проблема, силы и ограничения (ресурсы, ## Решение
стоимость, время на поддержку, существующая архитектура). Пиши так, чтобы
через год было понятно «почему это вообще делалось» без чтения переписки. Что именно решено — одной фразой.
## Почему
Намерение и причина. **Цитата из источника, а не пересказ.** Пиши так, чтобы
через год было понятно без чтения переписки.
## Рассмотренные варианты ## Рассмотренные варианты
<!-- Опциональная секция. Оставь, только если варианты реально <!-- Опциональная секция. Оставь, только если варианты реально
рассматривались. Если решение было единственным очевидным — удали рассматривались. Если решение было единственным очевидным — удали её,
её, а причину объясни в «Решении». --> а причину объясни в «Почему». -->
- **Вариант A** — суть, плюсы и минусы. - **Вариант A** — суть, почему отвергнут.
- **Вариант B** — суть, плюсы и минусы. - **Вариант B** — суть, почему отвергнут.
- **Вариант C** — если отвергнут сразу, коротко почему.
## Решение
Что именно сделано и — главное — **почему**: какое намерение и какая
причина за этим стоят. Если варианты рассматривались — почему выбран
этот, а не остальные.
## Последствия ## Последствия
- `+` что стало лучше, какие возможности открылись. - `+` что стало лучше.
- `-` чем платим: новые ограничения, риски, регулярная нагрузка на - `` чем платим: ограничения, риски, нагрузка на поддержку.
поддержку.
- Что нужно сделать как следствие (если есть).
+194
View File
@@ -0,0 +1,194 @@
# Архитектура
Обзор: как сложено и где что работает. **Поведение системы здесь не
описывается** — его нормативный дом `openspec/specs/`; ниже компоненты только
ссылаются на свои capability. Инварианты и их severity — в
[CLAUDE.md](../CLAUDE.md), схема хранилища — в [database.md](database.md),
периметр — в [security.md](security.md).
## Принципы
- **Один статический бинарь.** Доставка — образом с готовым бинарём внутри. См.
[ADR-2026-06-13-go-single-binary](adr/ADR-2026-06-13-go-single-binary.md).
- **Источник неприкосновенен.** Только `mkdir`, `link(2)` и `unlink` *своих*
целевых ссылок. См. [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md).
- **Выход распознавания недоверенный.** Безопасность держится на валидации
целевого пути, а не на промпте — [security.md](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](adr/ADR-2026-06-13-auto-link-requires-db-match.md).
- **Минимум компонентов.** В духе umbar — без зоопарка сервисов.
## Компоненты
`cmd/jellybit` — точка входа и сборка зависимостей; всё остальное — `internal/*`.
| Пакет | Ответственность | Capability |
| --- | --- | --- |
| `ingest` | use-case приёма загрузки, общий для всех транспортов | [ingest](../openspec/specs/ingest/spec.md) |
| `magnet`, `torrent` | разбор magnet-ссылки и байтов `.torrent`, извлечение инфохэшей | [ingest](../openspec/specs/ingest/spec.md) |
| `worker` | владелец машины состояний: поллинг qBittorrent, сериализация команд, фоновая сверка | [download-tracking](../openspec/specs/download-tracking/spec.md), [state-reconciliation](../openspec/specs/state-reconciliation/spec.md), [live-status](../openspec/specs/live-status/spec.md) |
| `qbt` | клиент qBittorrent WebUI API (сессия, добавление, опрос, удаление) | [download-tracking](../openspec/specs/download-tracking/spec.md) |
| `recognize` | пред-парс имени, вызов LLM, разбор плана, модель уверенности | [recognition](../openspec/specs/recognition/spec.md) |
| `llm` | провайдер LLM за интерфейсом (дискриминатор `[llm].type`) | [recognition](../openspec/specs/recognition/spec.md) |
| `metadata` | интерфейс метабаз + TMDB/TVDB/TVMaze (опц.) | [metadata-match](../openspec/specs/metadata-match/spec.md) |
| `naming` | единая логика целевых имён и отображаемого имени раздачи | [file-layout](../openspec/specs/file-layout/spec.md), [ingest](../openspec/specs/ingest/spec.md) |
| `layout` | санитизация путей, хардлинкер, copy-fallback, undo, владение путём | [file-layout](../openspec/specs/file-layout/spec.md), [state-reconciliation](../openspec/specs/state-reconciliation/spec.md) |
| `store` | SQLite: загрузки, распознавания, подсказки, кандидаты, ссылки | [identity](../openspec/specs/identity/spec.md) |
| `ident` | генерация и нормализация ULID | [identity](../openspec/specs/identity/spec.md) |
| `httpapi` | REST + веб-UI на htmx (server-rendered партиалы) | [web-ui](../openspec/specs/web-ui/spec.md), [review](../openspec/specs/review/spec.md) |
| `tgbot` | Telegram: приём, парсер сообщений торрент-бота, карточки, пинги | [notifications](../openspec/specs/notifications/spec.md), [review](../openspec/specs/review/spec.md) |
| `jellyfin` | триггер пересканирования медиатеки (опц.) | [file-layout](../openspec/specs/file-layout/spec.md) |
| `config` | загрузка и валидация TOML на старте | — |
| `logging`, `logctx` | slog-настройка и протяжка корреляции через контекст | [identity](../openspec/specs/identity/spec.md) |
| `archrules` | собственный анализатор архитектурных правил (часть гейта) | — |
Транспорты приёма — HTTP API, веб-UI, Telegram и CLI (`jellybit add`) — ведут в
один `ingest`; действия пользователя идут командами к `worker`. Перечень команд
и их эффекты — нормативно в [review](../openspec/specs/review/spec.md), пути
закрытия и удаления — в
[state-reconciliation](../openspec/specs/state-reconciliation/spec.md).
## Внешние границы и форматы
- **qBittorrent WebUI API** — единственный способ качать: источник **отдаём
ему**, сами по пользовательскому URL не ходим (SSRF исключён). Какие виды
источника принимаются и как разбираются —
[ingest](../openspec/specs/ingest/spec.md); способ добавления по
`source_type` — [download-tracking](../openspec/specs/download-tracking/spec.md);
откуда берутся пути файлов —
[file-layout](../openspec/specs/file-layout/spec.md).
- **LLM** — OpenAI-совместимый Chat Completions за интерфейсом (`[llm].type`);
контракт вызова, формат вывода и разбор ответа —
[recognition](../openspec/specs/recognition/spec.md).
- **Метабазы** — TMDB, TVDB, TVMaze (последняя без ключа, только сериалы).
- **Jellyfin** — HTTP-триггер пересканирования медиатеки, опционален; когда он
дёргается и по каким переходам —
[file-layout](../openspec/specs/file-layout/spec.md).
- **Telegram Bot API** — приём сообщений и исходящие карточки/пинги.
- **Сообщение торрент-бота** — чужой текстовый формат, разбирается парсером
`tgbot`; наблюдения по формату — в
[research/torrent-bot-message.md](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](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](adr/ADR-2026-08-10-sanitize-at-every-entry.md) |
| Разбор источника | `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](adr/ADR-2026-08-10-reason-computed-on-read.md) |
| Условие допуска полного удаления | `store.State.CanDelete()` — «из этого состояния удаление с файлами разрешено»; своего перечня состояний не заводит ни один транспорт (веб-UI, Telegram, страница группового удаления), а проверку в ядре предикат не заменяет: допуск держится без транспорта |
| Условие самообновления веб-UI | `store.State.IsObservable()` — «состояние ещё может измениться без человека»; транспорт своего перечня состояний не заводит, а поверхность (карточка списка, страница загрузки) держит **ровно один** поллер на обновляемый корень — [ADR-2026-08-10-observability-is-not-terminality](adr/ADR-2026-08-10-observability-is-not-terminality.md), правило разметки — [conventions/web-ui.md](conventions/web-ui.md) |
| Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) |
| Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) |
| Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям |
## Деплой
Работает в docker в одной среде с qBittorrent и Jellyfin — см.
[ADR-2026-07-24-local-image-build](adr/ADR-2026-07-24-local-image-build.md).
Сборка: статический бинарь (`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](../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 | лимита на размер ответа нет — единственный недоверенный канал без предела; задачей пока не заведено | — |
-58
View File
@@ -1,58 +0,0 @@
# Беклог
Единый список будущих задач по проекту: то, что уже решили сделать, и идеи,
которые ещё надо обдумать. Это **источник истины по беклогу** — одна задача = один
файл в этом каталоге. Не план реализации и не спецификация: принятое и
реализованное переезжает в [`docs/specs`](../specs)/[`docs/adr`](../adr), а сам
пункт беклога удаляется.
Приоритет — грубая оценка «ценность / стоимость», не обязательство к порядку.
Спекулятивные пункты (ещё без решения «делаем») помечены префиксом `[идея]` в
названии — их сперва надо проработать. Пункты, помеченные _(ревью 2026-07-08)_,
пришли из тщательного ревью ingest/worker/жизненного цикла (см. общий тег в теле).
Tududi (проект `jellybit`) больше **не** держит беклог — он служит только
инбоксом сырых идей. Прежде чем идея станет задачей, её оформляют файлом здесь.
## Высокий
- [Раздачи с докачиванием (merge при повторном добавлении)](merge-dokachivanie.md) — Свежий сериал раздают по мере выхода: торрент с 5 из 10 эпизодов позже перезаливают…
- [Ретеншн и очистка БД](retention-ochistka-bd.md) — Терминальные задачи (done/cancelled/failed/reverted), их попытки recognition с сырыми…
- [Eval-харнес распознавания (корпус кейсов + метрика точности)](eval-harness-raspoznavaniya.md) — Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую…
## Средний
- [Словарь единого языка (ubiquitous language)](ubiquitous-language-slovar.md) — Свести термины домена в один глоссарий, чтобы пользователь, документация, код и агент…
- [Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)](agenty-revyuvery-kachestva.md) — Ядро (specs+code ревьюверы) сделано и вшито в task-pipeline; остался ревьювер наименований (ждёт словарь единого языка)
- [[идея] Сила совпадения кандидата и пересмотр распознавания/матчинга](sila-sovpadeniya-kandidata.md) — ИДЕЯ (сперва проработать)
- [История переходов загрузки](istoriya-perehodov-zagruzki.md) — Сохранять полную историю переходов состояний загрузки (что/когда/почему/кто инициировал…
- [Привязка уведомлений к источнику в ботах (мульти-бот)](uvedomleniya-multi-bot.md) — Уведомления и запросы подтверждения должен получать тот, кто прислал загрузку: автор…
- [[идея] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки](slozhnye-serialnye-razdachi.md) — ИДЕЯ (проработать крайние случаи)
- [Аниме с абсолютной нумерацией](anime-absolyutnaya-numeraciya.md) — Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт…
- [Бэкап SQLite](backup-sqlite.md) — architecture
- [Глубокий healthcheck и статус зависимостей](healthcheck-zavisimosti.md) — /healthz проверяет только сам сервис
- [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](masshtab-100-zagruzok.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
- [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать…
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
- [`addReq` не пересобирается из свежего `source_type` перед Add (окно namer'а)](catched-source-type-namer-okno.md) — При апгрейде magnet→.torrent в окне namer'а добавится magnet из устаревшего снимка; самоисцеляется через magnet_timeout→failed→Retry _(аудит 2026-07-17)_
## Низкий
- [Ревью уведомлений в Telegram (аудит текстов и формата)](telegram-revyu-uvedomleniy.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает…
- [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
- [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки)
- [Согласование канона нумерации серий с провайдером тега](kanon-numeracii-vs-provajder.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега
- [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска…
- [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске
- [Кэш метабаз (и опционально LLM)](kesh-metabaz.md) — Повторные и ретраящиеся прогоны распознавания бьют TMDB/TVDB/TVMaze одним и тем же…
- [[идея] guessit как сервис-спутник](guessit-sputnik.md) — ИДЕЯ
- [[идея] Завершение загрузки через webhook](webhook-zavershenie-zagruzki.md) — ИДЕЯ (решим по опыту эксплуатации)
- [Авторизация веб-UI (на будущее)](avtorizaciya-web-ui.md) — Решено для v1: без авторизации в доверенной LAN, опц
- [Современный Web-UI как PWA](web-ui-pwa.md) — Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое, отзывчивое…
- [Идентичность инфохэшей: split v1/v2 одного торрента + крафт-магнет отравляет владение (F4, F5)](review-f4-f5-infohash-identity.md) — split v1/v2 идентичность и крафт-магнет отравляют владение инфохэшами _(ревью 2026-07-08)_
- [Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)](review-ingest-nits.md) — косметика приёма: NoName в контексте, устаревшие комментарии, лог, bencode-аллокации _(ревью 2026-07-08)_
- [Веб-UI зовёт Cancel вместо Dismiss на не-терминальных → теряется `user_dismiss`](dismiss-cancel-user-dismiss-marker.md) — Функционально ок (Cancel даёт cancelled), но маркер user_dismiss в error_code теряется; расхождение с буквой спеки _(аудит 2026-07-17)_
@@ -1,24 +0,0 @@
# Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)
**Приоритет:** средний
Набор узких сабагентов-ревьюверов поверх ревью-процесса из CLAUDE.md, каждый со своей оптикой: соответствие наименований словарю единого языка, соблюдение архитектурных границ (единое ядро/тонкие транспорты, инварианты безопасности данных), конвенций (ошибки, логирование, конфиг, TZ), стиля кода и поиск дублирования. Запускаются как чекпоинт перед archive/коммитом. Развивает ревью-процесс OpenSpec в сторону воспроизводимых автопроверок, не заменяя человеческое ревью.
## Сделано (2026-07-10)
- Заведены два кастомных ревьювера в `.claude/agents/`: `jellybit-review-specs`
(оптика спек/требований) и `jellybit-review-code` (архитектура, инварианты,
конвенции, стиль, дублирование).
- Оба подключены как чекпоинт в скилл `.claude/skills/task-pipeline` (ревью спек
ДО кода + ревью кода перед archive; на тривиальной задаче — один
`jellybit-review-code`, на нетривиальной — оба параллельно).
## Осталось
- **Ревьювер наименований** (соответствие словарю единого языка) — отдельной
оптикой пока не выделен: зависит от задачи «Словарь единого языка
(ubiquitous language)», без глоссария проверять не по чему. Завести после неё.
- По опыту эксплуатации — решить, дробить ли `jellybit-review-code` на более
узкие оптики (архитектура / конвенции / стиль+дублирование) или оставить одним.
Связано: CLAUDE.md (ревью-процесс, конвенции), docs/conventions, «Словарь единого языка», скилл `task-pipeline`.
@@ -1,7 +0,0 @@
# Аниме с абсолютной нумерацией
**Приоритет:** средний
Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт SxxEyy. Нужен пересчёт абсолютной нумерации в сезон/серию — надёжнее всего через TVDB (там есть absolute order). Отдельный крайний случай распознавания; на стороне ревью — веб-хелпер «absolute → S·E».
Связано: specs/recognition.md (конвейер, сезон-паки), specs/jellyfin-layout.md (нумерация серий), specs/review-ux.md.
@@ -1,58 +0,0 @@
# `addReq` не пересобирается из свежего `source_type` перед `Add` (окно namer'а)
**Приоритет:** средний · **Теги:** review-2026-07-17, lifecycle
Найдено аудитом capability **download-tracking** (сверка код↔спека после пачки
lifecycle-задач). Пред-существующее, вне scope задачи F3/cancel-cleanup — T4
осознанно вынес это за рамки и задокументировал в своём design.md.
## Суть
`processCatched` (`internal/worker/worker.go:442-517`) строит `addReq` из записи
`cur`, перечитанной под замком на `:447-455`, **до** вызова namer'а (LLM, секунды,
вне замка, `:463-478`). Затем на `:484-491` под замком перечитывается `before`,
но `addReq` из него **не пересобирается** — проверяется только `state == catched`.
Если апгрейд пойманной magnet-задачи до `.torrent`
(`UpgradeCatchedMagnetToTorrent`, `internal/store/download.go:392`) отработает
именно в окне namer'а (приём принял `.torrent` с тем же infohash, `state`
остаётся `catched`), воркер добавит **magnet-ссылку из устаревшего снимка**, хотя
в БД уже `source_type=torrent`.
Спека (`openspec/specs/download-tracking/spec.md`, раздел про добавление по
`source_type`) требует перечитывать `source_type` **под блокировкой переходов
непосредственно перед добавлением** — сейчас это требование в окне namer'а
нарушается.
## Насколько больно
Ограниченно и самоисцеляемо: на закрытом трекере magnet без метаданных зависнет
в `metaDL` → предохранитель `magnet_timeout``failed`; ручной `Retry`
перечитает актуальный `source_type` и добьёт. Данные не страдают, инвариант
«источник неприкосновенен» не задет. Окно узкое (апгрейд должен лечь ровно в
LLM-вызов по тому же infohash). Поэтому средний, не высокий.
## Развилка (решить до кода)
- **A — ужесточить код (соответствие букве спеки, закрыть окно):** после re-read
`before` под замком (`:484`) пересобирать `addReq`/`hint` из `before`, если
`source_type` изменился. Нюанс: `sourceAddParts` читает байты `.torrent` — это
тяжёлый вызов, держать под замком нельзя (спека: тяжёлое — вне блокировки), плюс
подсказка имени для `.torrent` иная (метаданные раздачи vs имя из magnet), т.е.
при апгрейде корректно был бы и повторный namer. Не однострочник.
- **B — смягчить спеку (принять реальность):** признать, что рациональ («не
полагаться на снимок, снятый ранее вне блокировки») уже выполнен первым re-read
под замком на `:447`, и переформулировать требование как «перечитывать
`source_type` под блокировкой после тик-снимка», явно приняв узкое namer-окно
как самоисцеляемое через `Retry`.
Рекомендация — начать с B (дёшево, отражает фактическое осознанное поведение), A
завести только если узкое окно окажется реальной болью в эксплуатации.
## Ссылки
- `internal/worker/worker.go:442-517``processCatched`
- `internal/store/download.go:392``UpgradeCatchedMagnetToTorrent`
- `openspec/specs/download-tracking/spec.md` — требование про `source_type`
- Тест `TestProcessCatchedReReadsSourceTypeUnderLock` покрывает апгрейд между
тик-снимком и re-read, но **не** окно namer'а.
-7
View File
@@ -1,7 +0,0 @@
# Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)
**Приоритет:** низкий
Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска — структура VIDEO_TS/ (DVD) или BDMV/ (BluRay). Сейчас распознавание и раскладка заточены под пофайловый разбор, а тут «фильм» — это каталог целиком. Jellyfin такие раскладки поддерживает (папка фильма с вложенным VIDEO_TS/BDMV). Нужно: распознать, что раздача — образ диска (по наличию VIDEO_TS/BDMV), не разбирать её по отдельным VOB/m2ts как серии, разложить весь каталог хардлинками в папку фильма (Название (Год)/VIDEO_TS/…). Крайний, но реальный случай; частота низкая.
Связано: specs/recognition.md (роли файлов), specs/jellyfin-layout.md (раскладка фильма), пакеты recognize, layout.
@@ -1,47 +0,0 @@
# Веб-UI зовёт Cancel вместо Dismiss на не-терминальных → теряется `user_dismiss`
**Приоритет:** низкий · **Теги:** review-2026-07-17, state-reconciliation
Найдено аудитом capability **state-reconciliation** (сверка код↔спека).
Пред-существующее, вне scope пачки lifecycle-задач.
## Суть
Спека `openspec/specs/state-reconciliation/spec.md` (требование «Ручное
закрытие»): команда `dismiss` доступна из **любого** состояния кроме `deleted`
**во всех транспортах**, и переход SHALL помечаться `error_code = user_dismiss`.
В веб-UI danger-zone «Закрыть» (dismiss) гейтится только для терминальных
состояний: `Dismissable = IsTerminal() && !deleted && !cancelled`
(`internal/httpapi/download.go:130`, шаблон
`web/templates/partials/download_main.html:99-112`). Для НЕ-терминальных
(`stuck`, `deferred`, `downloading`, `review`) закрытие в UI идёт кнопкой
«Отменить» → `Cancel` (`internal/worker/worker.go:973`), которая пишет **пустой**
`error_code`, а не `user_dismiss`.
## Насколько больно
Функционально сценарии проходят: `Cancel` тоже даёт `cancelled` и не трогает
файлы/раздачу, семантика для пользователя идентична. Состояния без доступного
«закрытия» нет (кроме `deleted`/`cancelled`). Теряется только маркер
`user_dismiss` в `error_code` — расхождение с буквой спеки и небольшая потеря
наблюдаемости (в аналитике/логах не отличить «пользователь закрыл активную» от
«пользователь отменил»). Отсюда низкий приоритет.
## Развилка (решить до кода)
- **A — привести код к спеке:** веб-UI на не-терминальных тоже зовёт `Dismiss`
ради единого маркера `user_dismiss`; либо `Cancel` пишет `user_dismiss`.
- **B — привести спеку к коду:** зафиксировать осознанное разделение (`Cancel`
для активных, `Dismiss` для терминальных) — уточнить требование, что стоп-кран
на не-терминальных реализуется `Cancel`'ом, и определить, какой `error_code`
ожидается.
Сначала решить, осознанно ли разделение Cancel/Dismiss; если да — вероятно B.
## Ссылки
- `internal/httpapi/download.go:130` — гейт `Dismissable`
- `web/templates/partials/download_main.html:99-112` — danger-zone
- `internal/worker/worker.go:973``Cancel`; `:1001``Dismiss`
- `openspec/specs/state-reconciliation/spec.md` — требование «Ручное закрытие»
@@ -1,7 +0,0 @@
# Eval-харнес распознавания (корпус кейсов + метрика точности)
**Приоритет:** высокий
Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую: регрессий не видно. Нужен корпус размеченных кейсов (русские релизы, аниме, сезон-паки, репаки, спецвыпуски) и прогон распознавания по нему с метрикой точности (тип/название/год/нумерация). Тогда можно сравнивать LLM-провайдеры и версии промпта по числам. Прогон — отдельной командой (jellybit eval или тестом), на фикстурах, без реального qBittorrent.
Связано: specs/recognition.md (конвейер, модель уверенности), пакет recognize.
@@ -1,47 +0,0 @@
# Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)
**Приоритет:** средний
Аудит спек↔код (2026-07-03) нашёл расхождение: спека recognition считает
`confidence` вспомогательным сигналом (условия авто — только матч в базе +
чистая валидация + согласованность), а код (`internal/recognize/validate.go:102`)
добавляет `confidence < threshold` (дефолт 0.85) четвёртым **блокирующим**
условием.
**Решение (2026-07-08): оставляем гейт (вариант B).** Низкий confidence — это
полезная доп. проверка на ревью: LLM могла ошибиться так, что под ошибочные
данные в базе нашёлся «такой же» фильм/сериал (ложный, но самосогласованный
матч — матч и валидация чисты, а модель при этом не уверена). Такой случай ловит
именно порог, уводя задачу в review, а не в авто. Значит confidence остаётся
законным условием — но его надо честно оформить.
Что сделать:
1. **Сделать гейт реально выключаемым.** Сейчас `recognize.go:173-175`
(`if threshold <= 0 { threshold = defaultAutoThreshold }`) не даёт выключить
порог: `0` в конфиге молча возвращается к дефолту. Убрать этот фолбэк — дефолт
задаётся один раз при загрузке конфига; значение `0` → гейт пропускается
`decide` уже `p.Confidence < 0` никогда не истинно, достаточно снять
пере-применение дефолта).
2. **Понизить дефолт** `recognition.auto_confidence_threshold` 0.85 → **0.7**
(`config.go:218`, `recognize.go:144 defaultAutoThreshold`). Условия 1–3 несут
корректность; порогу остаётся ловить только по-настоящему неуверенные планы.
3. **Записать в спеку.** В `openspec/specs/recognition/spec.md` (Requirement
«Модель уверенности и решение auto/review») переформулировать: confidence —
явное **четвёртое, конфигурируемое** блокирующее условие (порог
`recognition.auto_confidence_threshold`, `0` = выкл, дефолт 0.7), а не «лишь
вспомогательный сигнал». Добавить сценарий: матч + чистая валидация +
согласованность, но `confidence` ниже порога → review.
4. **Конфиг-конвенция/дока:** описать ключ в `docs/conventions/config.md` (диапазон
[0,1], 0 = выкл, дефолт 0.7).
5. **Тесты:** `decide` с `threshold=0` (гейт выключен, авто при чистых 1–3);
confidence ниже/выше порога при выполненных 1–3.
6. Точное число порога откалибровать позже — для этого есть задача
[eval-харнес распознавания](eval-harness-raspoznavaniya.md) (сейчас гейтим по
неизмеренному сигналу).
Оформить как OpenSpec-change (дельта `recognition` + правки
`validate.go`/`recognize.go`/`config`).
Связано: openspec/specs/recognition, ADR-2026-06-13-auto-link-requires-db-match,
[eval-харнес](eval-harness-raspoznavaniya.md), пакет recognize.
-7
View File
@@ -1,7 +0,0 @@
# [идея] guessit как сервис-спутник
**Приоритет:** низкий
ИДЕЯ. go-ptn слабее питоновского guessit. Если точности пред-парса не хватит — завернуть guessit в крошечный HTTP-сервис (один файл, поставляется рядом с бинарём jellybit) и спрашивать его на шаге пред-парса. Сохраняет «доставку копированием»: два файла вместо одного.
Связано: specs/recognition.md → «На будущее» (пред-парс).
@@ -1,7 +0,0 @@
# [идея] Многоступенчатая верификация привязки
**Приоритет:** низкий
ИДЕЯ (требует проработки). Несколько раз извлекать данные из раздачи и контекста разными промптами, искать в метабазах, затем сводить результаты в общий вердикт (голосование/консенсус) — выше точность ценой нескольких вызовов LLM и запросов к базам. Проработать: когда включать, как мерджить расхождения, стоимость/латентность.
Связано: specs/recognition.md (конвейер и модель уверенности).
-7
View File
@@ -1,7 +0,0 @@
# Обучение на правках человека (few-shot из прошлых ревью)
**Приоритет:** средний
Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать похожие в будущие промпты. Системно повышает точность на «твоих» трекерах и форматах имён без смены модели. Развитие идеи многоступенчатой верификации, но дешевле: учимся на уже собранных hint/override.
Связано: specs/recognition.md (конвейер, промпт), «Многоступенчатая верификация», specs/architecture.md → «Хранилище» (hint, override).
@@ -1,54 +0,0 @@
# Шум ERROR фоновых циклов при недоступной зависимости
**Приоритет:** низкий · **Теги:** review-fable, logging, reliability
Остаток от задачи «классификация доменных ошибок + конвенции логирования»
(основное реализовано, см. ниже). Здесь — два смежных пункта про уровень
повторяющихся сбоев фоновых циклов, каждый требует небольшого решения, а не
только правки.
## Что уже сделано (не переоткрывать)
Коммит `f8fb4fa` (Tier A) + коммит этой задачи закрыли:
- **Классификация доменных ошибок:** sentinel `worker.ErrInvalidInput`→400;
обёртки `ErrConflict` в Cancel/Retry/Defer/Undo; `layout.ErrCollision`→409 в
`classifyErr` и ветка в tgbot; `logCmd` относит новые классы в DEBUG.
- **Конвенции:** `logging.md` — команды воркера = доменная граница, таблица
уровней доменных отказов (граница команды vs асинхронная стадия), правило про
`*url.Error`/секреты в URL, канон категории `state transition` (унифицированы
cancel/retry/relink/recovery). `errors.md` — таблица маппинга ошибка→статус,
развилка «транзиентный ответ vs персистентная диагностика» решена как (а):
`error_msg`/`reasons` — операторская поверхность владельца (сырой текст ок,
секреты запрещены; аудит показал, что секреты туда не текут).
- **Мелочи:** reason-коды const-блок; лог-поля `id``download_id`; preview
WARN; комментарий у `parseIgnored`.
## Остаток
### ERROR-шторм при недоступном qBittorrent
Клиент `qbt` логирует `ext.*` `Failure`**ERROR** на каждом тике поллинга
(`torrents/info`, `internal/qbt/qbt.go`), пока qBittorrent недоступен (рестарт
демона, сеть). Домен уже пишет `poll failed` = WARN (по новой конвенции), но
транспортная `ext.*`-запись остаётся ERROR по правилу ext-конвенции («сервис
недоступен → ERROR»). При частом поллинге это шумит.
Развилка (решить до правки):
- (а) Ввести у `logging.ExtCall` вариант с пониженным уровнем для рутинно-частых
вызовов (симметрично `SuccessDebug`) — поллинг-вызовы (`torrents/info`) на
транзиентном сбое пишут WARN, не ERROR;
- (б) Дедуп/circuit-breaker: первый ERROR, дальше тишина до восстановления;
- (в) Оставить как есть, признав `ext.*` ERROR легитимным сигналом «зависимость
лежит» (тогда шум гасить уровнем сбора, а не кодом).
### Эскалация устойчивого сбоя тика
Сейчас транзиентный сбой тика = WARN всегда. Договорённость на будущее
(`logging.md`): устойчивый сбой N тиков подряд эскалировать в ERROR (реальная
деградация, а не разовый промах). Не реализовано — нужен счётчик подряд-сбоев по
циклу и порог в конфиге.
Вердикт: мелкая надёжностная полировка, не блокер. Делать вместе (обе про
уровень сбоев фоновых циклов) или отдельной строкой.
-15
View File
@@ -1,15 +0,0 @@
# Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)
**Приоритет:** низкий · **Теги:** ingest, review-2026-07-08
Ревью Fable 2026-07-08 (приём). Косметические нити.
N1 — torrent.go:114: Context() включает NoName-сентинел «-» как строку-название (name != "" проходит); ingest.parse фильтрует «-» только для source_ref (ingest.go:160-162). Одна грязная строка контекста для безымянных торрентов. Фикс: фильтровать «-» и в Context().
N3 — устаревшие комментарии. httpapi.go:574-577 и tgbot/bot.go:256-258 утверждают, что res.DownloadID может быть непуст при ошибке приёма («сбой после создания задачи, напр. qbit»). После fast-catch рефактора Ingest возвращает Result{} на КАЖДОМ пути ошибки (ingest.go:74-75,86,106-107) → корреляция всегда падает на request_id / без ключа. Фикс: поправить комментарии.
N4 — qbt.go:246 логирует «Fails.» со счётчиками, но qBittorrent не даёт причину; вместе с F2 оператор не отличит «дубль» от «битый файл». Идея: логировать хеши/первые байты для корреляции.
N5 — anacrolix bencode (v1.61.0, bencode/decode.go:17,250) аллоцирует до MaxStrLen (~128MiB) на объявленную строку до чтения — крафт-8MiB-торрент может форсить транзиентные ~128MiB аллокации при metainfo.Load. Ограничено и завершается ошибкой; на umbar приемлемо, но знать стоит. (files()-panic-guard torrent.go НЕ покрывает Load/UnmarshalInfo/HashBytes, но panic-путей там не найдено.)
Вердикт: простые фиксы/принять.
@@ -1,7 +0,0 @@
# [идея] Сила совпадения кандидата и пересмотр распознавания/матчинга
**Приоритет:** средний
ИДЕЯ (сперва проработать). У кандидата метабазы нет метрики силы совпадения (metadata_candidate хранит provider/id/title/year/url), решение «авто vs review» — по правилу «единственный сильный матч + валидация», не по числу. Для ревью: список кандидатов нечем отсортировать/подсветить по уверенности. Идея — ввести на этапе матча силу совпадения кандидата (точное совпадение названия+года vs частичное) для сортировки и подсказки в UI. Шире — продумать сам процесс распознавания и матчинга: границы «разбор LLM / поиск в базе / сверка», что храним у кандидата, как считаем и показываем уверенность.
Связано: specs/recognition.md, ADR-2026-06-13-auto-link-requires-db-match, specs/review-ux.md.
@@ -1,7 +0,0 @@
# [идея] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки
**Приоритет:** средний
ИДЕЯ (проработать крайние случаи). Обычный случай — один сезон (его номер видно глазами и сверяем на ревью — под это сделана сводка сезонов). Но в редких заказах раздача сложнее: все сезоны сериала разом, пак нескольких сезонов, смешанная нумерация, вложенные папки сезонов, разнобойные имена файлов. Сейчас PlanFile.Season задаётся на каждом файле (мультисезон в принципе выразим), но целостно эти сценарии не проработаны: как надёжно распознать, как показать на ревью, как разложить и как стыкуется со сходимостью папки и merge-докачиванием. Решить, что поддерживаем явно, а что уводим в ревью как «сложную раскладку».
Связано: specs/recognition.md, specs/jellyfin-layout.md, specs/review-ux.md, «Проблема второго сезона», «Раздачи с докачиванием».
+45 -16
View File
@@ -1,24 +1,53 @@
# Конвенции кода # Конвенции кода
Кросс-каттинг правила того, **как** мы пишем код (логирование, ошибки, Как мы пишем код — в отличие от `openspec/specs/`, который описывает, что
именование) — в отличие от `docs/specs/` и `openspec/specs/`, которые система делает, и от [../architecture.md](../architecture.md), который
описывают, **что** система делает. описывает, как она сложена.
Конвенции **не** переносятся в OpenSpec: это не capability. Короткие **Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
инварианты дублируются в [CLAUDE.md](../../CLAUDE.md) (агент читает его правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
всегда) и кратко в `openspec/config.yaml``context` (подмешивается в перечень «Механизировано» ниже. Причина: файл на несколько сотен строк
генерацию артефактов); детали — здесь. Обоснование «почему» — в `docs/adr/`. размазывает внимание по тривиальному — и модель, и человек добросовестно
проверят именование и не дойдут до формы решения. Процедура промоута —
`references/promote.md` скилла `av-dev-pipeline:review-pipeline`.
Обоснование «почему именно так» живёт в [../adr/](../adr/README.md); инварианты
с severity — в [CLAUDE.md](../../CLAUDE.md).
## Записи ## Записи
- [logging.md](logging.md) — логирование: уровни, поля, что не логируем. - [logging.md](logging.md) — логирование: уровень по адресату, единственный
- [config.md](config.md) — конфигурация: TOML, секреты через деплой логирующий чекпоинт, поля, `ext.*`, что не логируем.
(Ansible+Vault), валидация на старте.
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is`/`As`, - [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is`/`As`,
трансляция на внешней границе. трансляция доменной ошибки на внешней границе, sentinel против типизированной.
- [database.md](database.md) — БД и идентификаторы: TEXT ULID PK через - [config.md](config.md) — конфигурация: TOML, секреты рендерит деплой в файл
`internal/ident` (без AUTOINCREMENT), lowercase + нормализация на границах, `0600`, самодокументируемый `config.example.toml`, валидация на старте.
естественные ключи у деталей. - [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
ULID через `internal/ident`, `ident.Parse` на входной границе, естественные
ключи у деталей.
- [web-ui.md](web-ui.md) — веб-UI на htmx: единый партиал = страница = фрагмент, - [web-ui.md](web-ui.md) — веб-UI на htmx: единый партиал = страница = фрагмент,
ветвление `isHTMX`, деградация без JS, ошибка = 200 + фрагмент, самозавершающийся ветвление по `isHTMX`, деградация без JS, ошибка на htmx-пути = 200 +
поллинг, вендоринг/кэш статики. фрагмент, самозавершающийся поллинг, вендоринг и кэш статики.
## Механизировано
Проверяется `task gate`; прозой не дублируется и в промптах ревью не
пересказывается.
| Правило | Где механизировано |
| --- | --- |
| `msg` лога — константная категория, данные в полях, единый стиль ключ-значение | `.golangci.yml``sloglint` (`static-msg`, `kv-only`, `no-mixed-args`) |
| В stdout напрямую не пишем (`fmt.Print*`) | `.golangci.yml``forbidigo` |
| Конфигурация только из TOML, `os.Getenv` для конфига не используем | `.golangci.yml``forbidigo` |
| Время только через `store.Now()``time.Now` запрещён вне `internal/{ident,store}` | `.golangci.yml``forbidigo` |
| Сравнение ошибок через `errors.Is`/`As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Ошибки — только stdlib (`github.com/pkg/errors`, `cockroachdb/errors` запрещены) | `.golangci.yml``depguard` |
| Опечатки в тексте | `.golangci.yml``misspell` |
| Транспорты не зависят друг от друга | `internal/archrules``TestТранспортыНеЗависятДругОтДруга` |
| Ядро не зависит от транспортов | `internal/archrules``TestЯдроНеЗависитОтТранспортов` |
| Миграции без `AUTOINCREMENT` и без серверного времени | `internal/archrules``TestМиграцииБезAutoincrementИСерверногоВремени` |
| Ошибки не матчатся по тексту сообщения | `internal/archrules``TestОшибкиНеМатчатсяПоТексту` |
| Покрытие изменённых строк, секреты в диффе, миграция без правки `database.md` | `scripts/gate.py`, `scripts/diff-coverage.py`, `docs.py check` |
Непойманное место механизации означает, что проход по конвенциям будет
добросовестно проверять уже проверенное.
+15 -10
View File
@@ -6,17 +6,15 @@
Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел
«Конвенции кода». «Конвенции кода».
> Каркас. Загрузчик `internal/config/config.go` уже грузит TOML; валидация
> на старте — в работе (`TODO`), обкатывается на следующем шаге.
## Принципы ## Принципы
- **Конфигурация — только TOML.** Env-переменные для конфига **не - **Конфигурация — только TOML.** Env-переменные для конфига **не
используем**: окружение наследуется дочерними процессами и видно через используем**: окружение наследуется дочерними процессами и видно через
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`. `/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
Запрет `os.Getenv` механизирован (`forbidigo`).
- Грузим **один раз при старте** в одну типизированную структуру `Config` - Грузим **один раз при старте** в одну типизированную структуру `Config`
(под-структуры по секциям). Дальше по коду читаем только её — никаких (под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
`os.Getenv`/чтения файла в бизнес-коде, только загрузчик `internal/config`. бизнес-коде нет, только загрузчик `internal/config`.
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса. - Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
## Файл и поиск ## Файл и поиск
@@ -39,17 +37,24 @@
```toml ```toml
[worker] [worker]
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h) poll_interval = "<duration>" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration magnet_timeout = "<duration>" # ждать метаданные magnet не дольше; Go-duration
source_missing_threshold = 3 # тиков сверки без раздачи, чтобы счесть источник удалённым source_missing_threshold = <N> # тиков поллинга без раздачи, чтобы счесть источник удалённым
[recognition] [recognition]
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.01.0 auto_confidence_threshold = <0.01.0> # порог авто-раскладки без ревью; доля
[llm] [llm]
max_retries = 3 # попыток получить валидный ответ LLM; целое ≥ 0 max_retries = <N> # попыток получить валидный ответ LLM; целое ≥ 0
``` ```
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
начинают врать молча при смене умолчания. Действующие умолчания и их
смысл живут одним домом — таблица «Настройки с числовым значением» в
[../database.md](../database.md); `config.example.toml` — источник истины по
составу полей.
Секретные поля оставляем пустыми — значение приходит из деплоя (см. Секретные поля оставляем пустыми — значение приходит из деплоя (см.
«Секреты»). «Секреты»).
+11 -9
View File
@@ -1,14 +1,16 @@
# Конвенция: база данных и идентификаторы # Конвенция: база данных и идентификаторы
Как мы устраиваем таблицы и ключи в SQLite. Актуальная схема — Как мы устраиваем таблицы и ключи в SQLite. Актуальная схема —
[../specs/database.md](../specs/database.md); обоснование выбора ULID — [../database.md](../database.md); обоснование выбора ULID —
`openspec/changes/ulid-identity/design.md` (после архивации — в истории git). [архивный design.md change'а `ulid-identity`](../../openspec/changes/archive/2026-07-02-ulid-identity/design.md).
**Механизировано:** `AUTOINCREMENT` и `DEFAULT (datetime('now'))` в новых
миграциях (`internal/archrules`), время мимо `store.Now()` (`forbidigo`).
## Первичные ключи — ULID, не автоинкремент ## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется - **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи. `INTEGER PRIMARY KEY **приложением** в момент создания записи.
AUTOINCREMENT` в новых таблицах не используем.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология), - Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL/логах (без дефисов — grep и двойной клик берут id компактен и удобен в URL/логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален across таблиц — поиск по голому id находит целиком), глобально уникален across таблиц — поиск по голому id находит
@@ -43,11 +45,11 @@
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет `2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT = хронологию (`ORDER BY created_at`). лексикографическую сортировку TEXT = хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение: `store.Now()` + `store.FormatTime`/ Единая точка генерации — приложение: `store.Now()` + `store.FormatTime`/
`ParseTime` (аналогично `ident.NewID` для id); `DEFAULT (datetime('now'))` на `ParseTime` (аналогично `ident.NewID` для id), а не дефолт в схеме — так
колонках **не используется** (fail-loud при забытой вставке: `NOT NULL` без забытая вставка падает громко. Измерение длительности — не метка времени: у
дефолта). Зона хранения всегда UTC; таймзона отображения в UI — конфиг внешних вызовов его засекает `logging.StartCall`. Таймзона отображения в
`[general].timezone`. UI — конфиг `[general].timezone`.
- Миграции — goose (`internal/store/migrations`): SQL-файлы для DDL; - Миграции — goose (`internal/store/migrations`): SQL-файлы для DDL;
Go-миграции (`goose.AddMigrationContext`) — когда нужен код (генерация Go-миграции (`goose.AddMigrationContext`) — когда нужен код (генерация
id, backfill). При изменении структуры обновляем ER-схему id, backfill). При изменении структуры обновляем ER-схему
[../specs/database.md](../specs/database.md) в том же change. [../database.md](../database.md) в том же change.
+27 -9
View File
@@ -5,11 +5,13 @@
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как
ошибки строятся, оборачиваются и проверяются. ошибки строятся, оборачиваются и проверяются.
**Механизировано:** сторонние пакеты ошибок — `depguard`; `err == ErrX` и
приведение типа — `errorlint`; матчинг по тексту сообщения — `internal/archrules`.
## Базовая идиома: stdlib ## Базовая идиома: stdlib
- Только стандартный `errors` + `fmt.Errorf`. Без `pkg/errors` (в режиме - Только стандартный `errors` + `fmt.Errorf`: контекст ошибки несёт `slog`, а не
поддержки) и `cockroachdb/errors` (стек-трейсы/Sentry избыточно для стек — стек-трейсы и Sentry избыточны для домашнего сервиса.
домашнего сервиса). Контекст ошибки несёт `slog`, а не стек.
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал - Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
пересмотреть, а не дефолт. пересмотреть, а не дефолт.
@@ -36,9 +38,6 @@ jellybit — **приложение, а не библиотека**: внешн
## Проверка ошибок ## Проверка ошибок
- Сравнение — только `errors.Is(err, ErrX)` (не `err == ErrX`) и
`errors.As(err, &target)`. **Никогда** не матчим по тексту
(`strings.Contains(err.Error(), …)`).
- Граничные ошибки зависимостей **транслируем в доменные у источника**: - Граничные ошибки зависимостей **транслируем в доменные у источника**:
`sql.ErrNoRows` → доменный `store.ErrNotFound` в слое store, чтобы выше по `sql.ErrNoRows` → доменный `store.ErrNotFound` в слое store, чтобы выше по
коду не торчал `database/sql`. коду не торчал `database/sql`.
@@ -67,7 +66,15 @@ jellybit — **приложение, а не библиотека**: внешн
- **+ корреляционный ключ** для владельца — `download_id` (если операция - **+ корреляционный ключ** для владельца — `download_id` (если операция
к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах. к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах.
Пример: «При обработке загрузки произошла ошибка, download_id=12345», а Пример: «При обработке загрузки произошла ошибка, download_id=12345», а
не «произошла ошибка» и не сырой текст; не «произошла ошибка» и не сырой текст.
**Ключ есть не у всякого транспорта, и это называется вслух.** `request_id`
— понятие HTTP-границы (chi `RequestID`); у Telegram и CLI его нет. Если
операция ещё не завела загрузку (отказ приёма), у такого транспорта ключа
нет вовсе — тогда сообщение остаётся без якоря, а диагностика ищется по
записи доменной границы (`capability`, `infohash`). Заводить транспорту
собственный идентификатор запроса ради ключа — решение уровня спеки, а не
умолчание: второй канал корреляции рядом с существующим дороже, чем
отсутствие ключа;
- **маппинг доменной ошибки → статус/сообщение** (в jellybit — - **маппинг доменной ошибки → статус/сообщение** (в jellybit —
`httpapi.classifyErr`, единая точка для REST и веб-UI): `httpapi.classifyErr`, единая точка для REST и веб-UI):
@@ -75,9 +82,14 @@ jellybit — **приложение, а не библиотека**: внешн
|---|---|---| |---|---|---|
| `store.ErrNotFound` | 404 | «не найдено» | | `store.ErrNotFound` | 404 | «не найдено» |
| `magnet.ErrNotMagnet` / `torrent.ErrNotTorrent` | 400 | «некорректный источник» | | `magnet.ErrNotMagnet` / `torrent.ErrNotTorrent` | 400 | «некорректный источник» |
| `ingest.ErrTorrentTooLarge` (файл больше лимита) | 400 | «файл .torrent слишком большой» |
| `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» | | `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» |
| `errManualSource` (ручной ввод источника, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
| `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
| `errBatchEmpty` / `errBatchTooLarge` / `errBatchBadID` (разбор пачки группового удаления, локальные sentinel'ы `httpapi`) | 400 | текст самой ошибки |
| `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» | | `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» |
| `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» | | `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» |
| `layout.ErrNameTooLong` (целевое имя не помещается, ушло в review) | 409 | «целевое имя слишком длинное…» |
| `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» | | `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» |
| прочее | 500 | «внутренняя ошибка» | | прочее | 500 | «внутренняя ошибка» |
@@ -98,7 +110,7 @@ jellybit — **приложение, а не библиотека**: внешн
в review/failed: коллизия, рассинхрон, сбой ФС) и `reasons` распознавания, в review/failed: коллизия, рассинхрон, сбой ФС) и `reasons` распознавания,
сохранённые в БД и показываемые на экране ревью и в Telegram-карточке. Это сохранённые в БД и показываемые на экране ревью и в Telegram-карточке. Это
**операторская поверхность владельца**: сервис однопользовательский в **операторская поверхность владельца**: сервис однопользовательский в
доверенной LAN (см. [architecture.md](../specs/architecture.md)), эти поля — доверенном контуре (см. [security.md](../security.md) → «Периметр»), эти поля —
диагностический контекст для того, кто разбирает задачу. Здесь сырой текст диагностический контекст для того, кто разбирает задачу. Здесь сырой текст
ошибки (пути, фрагмент ответа LLM/qBittorrent) **допустим и полезен** — но: ошибки (пути, фрагмент ответа LLM/qBittorrent) **допустим и полезен** — но:
- **секреты запрещены** абсолютно (токены/ключи/пароли/`Authorization`) — так - **секреты запрещены** абсолютно (токены/ключи/пароли/`Authorization`) — так
@@ -106,7 +118,13 @@ jellybit — **приложение, а не библиотека**: внешн
error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок
транспорта, несущих URL с секретом); транспорта, несущих URL с секретом);
- это **не** канал для транзиентных отказов команд — те остаются нейтральными - это **не** канал для транзиентных отказов команд — те остаются нейтральными
(см. выше). (см. выше);
- **внешнее значение в тексте усекается на границе, а его размер называется
числом.** `error_msg` уезжает в баннер ревью, в панель действий и в карточку
Telegram; имя файла на 400 байт занимает там экран целиком и оседает в БД
навсегда. Усечение — серединой и по рунам (`layout.shorten`,
`naming.truncate`, `tgbot.shorten`), точная величина остаётся числом рядом:
без неё человек не поймёт, насколько сокращать.
## panic ## panic
+21 -33
View File
@@ -8,14 +8,16 @@ OpenSpec-спеках (`### Requirement` с `SHALL`).
Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел
«Конвенции кода». «Конвенции кода».
**Механизировано** (`.golangci.yml`): `slog` вместо `fmt.Print*``forbidigo`;
константный `msg` и стиль ключ-значение — `sloglint`. Ниже — только то, что
правилом не выражается.
## Принципы ## Принципы
- Только `log/slog`, без `fmt.Println` и прямой записи в stdout.
- Структурированный JSON (`slog.JSONHandler`), один формат для dev и prod. - Структурированный JSON (`slog.JSONHandler`), один формат для dev и prod.
- Сообщение (`msg`) — константный шаблон/категория события; данные — в - Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
полях (атрибутах `slog`), а не в интерполяции текста. отдельный ключ с типизированным значением: это даёт фильтрацию и агрегацию
- Каждое поле — отдельный ключ с типизированным значением. Это даёт через `jq`/DuckDB без регулярок.
фильтрацию и агрегацию через `jq`/DuckDB без регулярок.
```json ```json
{"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"} {"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"}
@@ -24,18 +26,8 @@ OpenSpec-спеках (`### Requirement` с `SHALL`).
## Сообщение ## Сообщение
- `msg` — короткая константа в нижнем регистре: `download accepted`, - `msg` — короткая константа в нижнем регистре: `download accepted`,
`recognition done`, `layout failed`. Без переменных в тексте. `recognition done`, `layout failed`. Данные — в атрибутах:
- Данные кладём в атрибуты: `slog.Info("download accepted", "download_id", `log.Info("download accepted", "download_id", id, "media_type", "movie")`.
id, "infohash", ih)`.
```go
// Правильно: msg — категория, данные — поля
log.Info("download accepted", "download_id", id, "media_type", "movie")
// Неправильно: данные зашиты в текст, агрегация ломается
log.Info(fmt.Sprintf("download %s accepted as movie", id))
```
- `msg` — чистая категория без неймспейс-префикса: `recognition done`, а не - `msg` — чистая категория без неймспейс-префикса: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability` `recognize: done`. Подсистему выносим в поле `capability`
(`ingest`/`recognition`/`file-layout`/`review`), не в текст. (`ingest`/`recognition`/`file-layout`/`review`), не в текст.
@@ -82,9 +74,10 @@ log.Info(fmt.Sprintf("download %s accepted as movie", id))
- Поле — `time` (ключ по умолчанию `slog`). - Поле — `time` (ключ по умолчанию `slog`).
- UTC, RFC 3339 с долями секунды, суффикс `Z`: - UTC, RFC 3339 с долями секунды, суффикс `Z`:
`2026-06-28T11:23:45.123456Z`. `2026-06-28T11:23:45.123456Z`.
- Логи — **в UTC** (это явный TZ, не нарушает инвариант проекта): даёт - Логи — **в UTC**, как и хранение в БД: это даёт однозначный порядок событий и
однозначный порядок событий и лексикографическую сортировку. Бизнес-логика лексикографическую сортировку. Часовой пояс есть только у **отображения** в
по-прежнему работает в `Europe/Moscow` — UTC только в логах. веб-UI (`[general].timezone`, дефолт `UTC`) — см.
[database.md](../database.md); бизнес-логика в локальной зоне не работает.
## Поля: словарь имён ## Поля: словарь имён
@@ -131,20 +124,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
## Ошибки ## Ошибки
Go-ошибки логируем как атрибут, не как текст сообщения. Go-ошибки логируем как атрибут, не как текст сообщения:
`log.Error("layout failed", "error", err, "download_id", id)`. Ключ — `error`
(как по умолчанию в zap/zerolog; единый ключ важнее краткости).
```go
// Правильно: msg — категория, ошибка — поле
log.Error("layout failed", "error", err, "download_id", id)
// Неправильно: ошибка зашита в msg, агрегация по событию ломается
log.Error(err.Error())
```
Правила:
- Ошибку передаём полем `"error", err` — не склеиваем в `msg`. Ключ —
`error` (как по умолчанию в zap/zerolog; единый ключ важнее краткости).
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только - Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя —
контекст накапливается в цепочке `%w`. контекст накапливается в цепочке `%w`.
@@ -221,6 +204,11 @@ log.Error(err.Error())
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`, - Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
`http.status_code`, `duration_ms`, `transport` (`http`/`web`/`telegram`). `http.status_code`, `duration_ms`, `transport` (`http`/`web`/`telegram`).
- **Поле, которое уже даёт scoped-логгер, руками не доклеиваем.** Команда,
положившая scoped-логгер в `ctx`, не передаёт `download_id` ещё и аргументом
записи: в JSON получается дублирующийся ключ, и строгий потребитель молча
оставит одно из значений. Правило следует из «логгер несёт ключи сам» и
проверяется чтением — линтером не выражается.
- Для корреляции HTTP-запроса допустим `request_id` (напр. chi `RequestID`) — - Для корреляции HTTP-запроса допустим `request_id` (напр. chi `RequestID`) —
это отдельный слой от корреляции загрузки по `download_id` и не противоречит это отдельный слой от корреляции загрузки по `download_id` и не противоречит
отказу от `trace_id`. Если запрос порождает загрузку — связь даёт отказу от `trace_id`. Если запрос порождает загрузку — связь даёт
+35 -14
View File
@@ -103,27 +103,48 @@ htmx по умолчанию **не свопит DOM на ответы 4xx/5xx**
## Живой поллинг ## Живой поллинг
Паттерн живого обновления: фрагмент-эндпоинт под `/fragments/...` + в разметке Паттерн живого обновления: фрагмент-эндпоинт под `/fragments/...` + в разметке
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`талон — `hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`. Эталон — карточка
`progress`/`seeding`, `handleFragProgress`/`handleFragSeeding`): списка (`card`, `handleFragCard`):
```html ```html
{{define "progress"}}<div id="dl-live-{{.ID}}" {{define "card"}}<article class="card" id="card-{{.ID}}"
{{if .Active}} hx-get="/fragments/downloads/{{.ID}}/progress" {{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card"
hx-trigger="every 3s" hx-swap="outerHTML"{{end}}> hx-trigger="every {{.PollEvery}}" hx-swap="outerHTML"{{end}}>
... ...
</div>{{end}} </article>{{end}}
``` ```
- **Поллер самозавершается.** Когда состояние выходит из «живого» (`Active` - **Один поллер на обновляемый корень.** Опрашивает себя корень поверхности
ложно, торрент не сидирует), фрагмент возвращается **без `hx-*`** — htmx (карточка списка, главная область страницы), а вложенные живые регионы —
больше не опрашивает. Условие «живости» ведёт store-состояние (`downloading` прогресс качания, секция раздачи — своего `hx-get` **не несут**: своп корня
для прогресса), а не qBittorrent. уносит их вместе с таймером, и два опроса подменяли бы разметку друг друга.
Живые цифры приезжают вместе с корнем.
- **Поллер самозавершается.** Опрос ведётся, пока предмет может измениться без
участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx
больше не опрашивает. Условие определяется store-состоянием
(`State.IsObservable()`), а не qBittorrent.
- **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает
`200` и фрагментом с объяснением **без `hx-*`**: htmx не свопит `4xx/5xx`,
поэтому статус ошибки оставил бы поверхность навсегда прежней, а опрос —
бесконечным. Фрагмент отказа обязан нести корневой `id` того узла, который он
собой заменяет (см. инвариант выше), иначе `hx-swap` подменит не тот узел.
- **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный ретрай;
`ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)).
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его - **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
поллером и htmx `process`-инициализирует новый — двойного опроса нет **при поллером и htmx `process`-инициализирует новый — двойного опроса нет **при
условии совпадения корневого `id`** (см. инвариант выше). условии совпадения корневого `id`** (см. инвариант выше). Эфемерное состояние
- Данные тика — из in-memory снимка воркера (`LiveStatus.Live(infohash)`), без разметки своп не переживает: то, что должно пережить тик (раскрытый
БД/сети на каждый тик; узкий контракт `LiveStatus` не зависит от способа `<details>`), помечается `hx-preserve`.
доставки (поллинг сейчас, путь к SSE оставлен изолированным). - **Частота — по цене тика, и она названа числом в
[database.md](../database.md).** Поверхность с живыми цифрами качания
обновляется чаще (`pollFast`, вровень с частотой опроса qBittorrent — быстрее
источника опрашивать бессмысленно), прочие наблюдаемые — реже (`pollSlow`).
- **Тик ходит в БД, и это цена решения.** Живые цифры берутся из in-memory
снимка воркера (`LiveStatus.Live(infohash)`), но состояние и размер раскладки
тик читает из хранилища, а тик страницы загрузки ещё и считает предпросмотр
раскладки с обходом ФС — отсюда и разные интервалы. Узкий контракт
`LiveStatus` при этом не зависит от способа доставки (поллинг сейчас, путь к
SSE оставлен изолированным).
- **Инвариант: браузер не опрашивает qBittorrent напрямую** — только свой сервер, - **Инвариант: браузер не опрашивает qBittorrent напрямую** — только свой сервер,
который читает снимок. Поллинг статуса UI логируем на `DEBUG` (рутинно-частое, который читает снимок. Поллинг статуса UI логируем на `DEBUG` (рутинно-частое,
см. [logging.md](logging.md)). см. [logging.md](logging.md)).
+224
View File
@@ -0,0 +1,224 @@
# Схема хранилища
Актуальная схема SQLite: таблицы, поля и связи. Это **живой** документ — его
поддерживаем в соответствии с миграциями.
> **Поддержка вместе с миграциями.** Источник истины по схеме —
> `internal/store/migrations/*.sql` (goose). При **каждой** новой миграции,
> меняющей структуру (таблица/столбец/индекс/связь), обновляем эту диаграмму
> в том же change. Расхождение схемы с миграциями считаем багом документации;
> его же ловит `docs.py check` в гейте.
>
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`,
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`),
> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC,
> `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла),
> `0010_retried_at`, `0011_parsed_context` (структура имени из контекста, JSON).
Назначение таблиц и роль компонентов — [architecture.md](architecture.md).
Значения `state` и легальные переходы — нормативно в
[download-tracking](../openspec/specs/download-tracking/spec.md) и
[state-reconciliation](../openspec/specs/state-reconciliation/spec.md).
Первичные ключи — ULID (TEXT, lowercase), генерятся приложением
(`internal/ident`) — см. [конвенцию](conventions/database.md). Метки времени
(`created_at`/`updated_at`) — TEXT в RFC 3339, UTC (суффикс `Z`); пишет
приложение (`store.Now`/`FormatTime`), без `DEFAULT` на колонках.
## ER-диаграмма
```mermaid
erDiagram
download ||--o{ download_infohash : "инфохэши (v1/v2)"
download ||--o| download_torrent : "байты .torrent (1:0..1)"
download ||--o{ recognition : "распознавания"
download ||--o{ hint : "подсказки"
download ||--o{ override : "ручные правки"
download ||--o{ file_link : "хардлинки"
recognition ||--o{ metadata_candidate : "кандидаты базы"
download {
TEXT id PK "ULID (lowercase), генерится приложением"
TEXT source_type "NOT NULL; magnet|torrent|url"
TEXT source_ref "NOT NULL; magnet/url/путь"
TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)"
TEXT context "NOT NULL DEFAULT ''"
TEXT parsed_context "NOT NULL DEFAULT ''; структура имени из контекста (naming, JSON), базовый слой display_name (миграция 0011)"
TEXT state "NOT NULL; активность выводится только из state"
TEXT error_code "nullable"
TEXT error_msg "nullable"
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
TEXT retried_at "nullable; время последнего ручного retry (RFC 3339 UTC Z), сброс базиса таймаутов (миграция 0010)"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
download_infohash {
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; PK(infohash, download_id)"
TEXT infohash PK "NOT NULL; lowercase hex (40 — v1, 64 — v2)"
TEXT kind "NOT NULL; v1|v2"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
download_torrent {
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; байты source_type=torrent"
BLOB data "NOT NULL; исходные байты .torrent для добавления файлом (миграция 0009)"
}
recognition {
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
INTEGER attempt_no "NOT NULL DEFAULT 1"
INTEGER is_current "NOT NULL DEFAULT 1; 0/1"
TEXT media_type "nullable; movie|series"
TEXT title "nullable"
TEXT original_title "nullable"
INTEGER year "nullable"
TEXT provider "nullable; tmdb|tvdb|tvmaze|none"
TEXT provider_id "nullable"
REAL confidence "nullable"
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто"
TEXT raw_llm "nullable; сырой ответ LLM"
TEXT plan "nullable; JSON recognize.Plan (миграция 0002)"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
hint {
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT text "NOT NULL"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
override {
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT field "NOT NULL; UNIQUE(download_id, field)"
TEXT value "NOT NULL"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
metadata_candidate {
TEXT id PK "ULID"
TEXT recognition_id FK "NOT NULL; ON DELETE CASCADE"
TEXT provider "NOT NULL"
TEXT provider_id "NOT NULL"
TEXT title "nullable"
INTEGER year "nullable"
TEXT url "nullable; ссылка на страницу на сайте провайдера"
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
file_link {
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT apply_batch_id "NOT NULL; батч для точечного undo"
TEXT src_path "NOT NULL; исходный файл раздачи"
TEXT dst_path "NOT NULL; целевой хардлинк"
TEXT kind "NOT NULL; video|subtitle|..."
TEXT status "NOT NULL; linked|copied|exists|collision|superseded"
INTEGER size "NOT NULL DEFAULT 0; размер файла (байт), фолбэк размера раздачи"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
```
## Связи и кардинальность
- `download` 1 — N `download_infohash` / `recognition` / `hint` / `override`
/ `file_link`; `recognition` 1 — N `metadata_candidate`. Все дочерние — с
`ON DELETE CASCADE`: удаление загрузки уносит её хеши, распознавания,
подсказки, правки и ссылки.
- `download_infohash` — множество хешей одной загрузки (v1/v2 гибридного
торрента); один и тот же infohash может принадлежать нескольким загрузкам
во времени (повторный приём после терминального состояния). Инвариант «не
более одной активной загрузки на infohash» держат guarded-методы store
(`CreateDownloadIfNoActive`/`ActivateIfNoOtherActive`) в одной
write-транзакции — на уровне схемы он не выражается (условие на `state`).
- `download` 1 — 0..1 `download_torrent` — байты исходного `.torrent` (только
у `source_type=torrent`); нужны воркеру для добавления раздачи файлом и для
повторного добавления при retry, поэтому живут весь срок строки загрузки.
- `download``file_link` — один источник (раздача) ко многим разложенным
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
каждый файл раздачи попадает в `file_link` (только распознанные медиа и
субтитры); ссылки могут накапливаться несколькими `apply_batch_id`.
## Индексы и ограничения
- `download`: индекс по `state`.
- `download_infohash`: PK `(infohash, download_id)` (он же индекс поиска по
хешу); индекс по `download_id`.
- `recognition`: индекс по `download_id`.
- `override`: `UNIQUE(download_id, field)`.
- `metadata_candidate`: индекс по `recognition_id`.
- `file_link`: индексы по `download_id` и по `apply_batch_id`.
> Enum-поля (`source_type`, `state`, `provider`, `kind`, `status`, флаги
> `0/1`) на уровне SQLite — обычный `TEXT`/`INTEGER` без `CHECK`; допустимые
> значения держит код (`internal/store`).
## Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- **Всё, кроме одного поля, — плоские колонки.** Никакого сжатия, никаких
внешних файлов: строка читается и пишется целиком обычным запросом.
- **JSON-строками в TEXT** лежат три поля: `recognition.plan` (канонический
`recognize.Plan` — файл → роль/сезон/серия), `recognition.reasons` (список
причин не-авто) и `download.parsed_context` (структура имени из контекста).
Читаются целиком и разбираются в Go; частичного чтения и обновления поля
внутри JSON нет, SQL по содержимому этих полей не делается.
- **`recognition.raw_llm`** — сырой ответ модели как есть, **несжатый**. Это
самое крупное поле в базе и главный кандидат на рост: у каждой попытки
распознавания свой ответ, попытки не вытесняются, ретеншена нет
(задача в беклоге).
- **`download_torrent.data`** — единственный BLOB: исходные байты `.torrent`
(обычно десятки КБ, у больших раздач — сотни). Читается целиком при
добавлении в qBittorrent и при retry.
- **Истории переходов нет** — хранится только текущий `state`; «как сюда
попали» восстанавливается по логам (задача в беклоге).
- **Терминальные загрузки не удаляются**, `file_link` со статусом `superseded`
тоже остаются — база монотонно растёт по числу обработанных раздач.
## Настройки с числовым значением
СУБД (`internal/store`, DSN при открытии):
| Настройка | Значение | Зачем |
| --- | --- | --- |
| `journal_mode` | `WAL` | читатели не блокируют писателя |
| `busy_timeout` | 5000 мс | ждать снятия блокировки, а не падать сразу `database is locked` |
| `foreign_keys` | `ON` | `ON DELETE CASCADE` работает только с этим |
| `_txlock` | `immediate` | явная транзакция открывается как write с самого начала; на этом держатся guarded-методы инварианта «одна активная загрузка на infohash» |
| Размер пула | по умолчанию `database/sql` | явно не ограничен; писателя SQLite сериализует сама |
Времена и пороги, влияющие на объём и частоту работы с базой (значения по
умолчанию, `config.example.toml` — источник истины по полям):
| Параметр | По умолчанию | Что означает |
| --- | --- | --- |
| `[worker].poll_interval` | `5s` | частота опроса qBittorrent, а значит и фонового чтения/записи состояния |
| `[worker].stuck_after` | `1h` | простой раздачи, после которого она считается зависшей |
| `[worker].magnet_timeout` | `24h` | страховочный предел ожидания метаданных magnet |
| `[worker].catch_timeout` | `10m` | предел для пойманной задачи, не добавившейся в qBittorrent |
| `[worker].source_missing_threshold` | `3` тика | дебаунс пропажи источника |
| `[recognition].auto_confidence_threshold` | `0.85` | порог авто-раскладки (доп. проверка к матчу в базе) |
| `[llm].timeout` / `max_retries` | `120s` / `3` | каждая попытка порождает строку `recognition` с сырым ответом |
| `[metadata.*].timeout` | `10s` | таймаут запроса к метабазе |
Пределы, зашитые константой кода, а не полем конфига:
| Константа | Значение | Что означает |
| --- | --- | --- |
| `ingest.MaxTorrentSize` | `8 MiB` | предел размера принимаемого `.torrent`; проверяется **до** разбора, поэтому bencode-аллокации на эту величину не масштабируются (см. [research/torrent-bencode-limits.md](research/torrent-bencode-limits.md)) |
| `httpapi.pollFast` | `5s` | интервал самообновления поверхности с живыми цифрами качания (карточка в `downloading`). Держится вровень с `[worker].poll_interval`: снимок телеметрии обновляется тиком воркера, и опрос чаще возвращает тот же снимок. Меняется `poll_interval` — меняется и эта константа |
| `httpapi.pollSlow` | `15s` | интервал самообновления прочих наблюдаемых поверхностей: карточек вне `downloading` и страницы `/download/{id}` в любом состоянии. Тик страницы считает предпросмотр раскладки и ходит в ФС, поэтому частота у него ниже |
| `httpapi.maxBulkDelete` | `20` загрузок | предел размера одной пачки группового удаления. Подтверждение, перечисляющее больше, человек не читает — то есть перестаёт быть подтверждением; плюс один синхронный запрос упирается в столько же последовательных вызовов qBittorrent. Предел называет сама страница выбора; отказ по пределу возвращает выбор с сохранёнными отметками |
| `httpapi.bulkFailThreshold` | `3` отказа подряд | сколько подряд идущих отказов внешнего сервиса прекращают проход группового удаления. Удаление снимает библиотечные ссылки раньше, чем сносит раздачу: при недоступном qBittorrent каждая единица успевает выполнить необратимый локальный шаг и упасть на внешнем. Счётчик сбрасывается на успехе; конфликт состояния системным отказом не считается |
| `httpapi.bulkBudget` | `2` минуты | потолок времени на один проход группового удаления. Удаление держит общий замок воркера на всё время обращения к qBittorrent, поэтому медленно, но успешно отвечающий сосед остановил бы фоновую работу целиком, а порог отказов такого не ловит. Проверяется между единицами: начатое удаление не обрывается, иначе оно встанет между снятием ссылок и сносом раздачи |
| `layout.maxComponentBytes` | `255` байт | предел длины компонента целевого пути (`NAME_MAX` у ext4/xfs/btrfs); меряется в байтах UTF-8, проверяется **до** первой операции с ФС, отказ уводит задачу в `review` с кодом `name_too_long`. У ядра не выясняется; на ФС с меньшим пределом остаётся отказ ядра — лечение правкой константы, а не настройкой |
**Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет,
кэша метабаз нет; состояние по каждому пробелу и заведённые под них задачи —
[architecture.md](architecture.md) → «Открытые вопросы».
-67
View File
@@ -1,67 +0,0 @@
# Конвенции кода: бэклог
Кандидаты в [docs/conventions/](../conventions/README.md), ещё не принятые.
Пишем по мере реального трения, а не вперёд; принятое переезжает в
`docs/conventions/`. Уже приняты: `logging.md`, `config.md`, `errors.md`.
## Tier 2 — кандидаты в отдельный доку
Завести, когда паттерн подтвердит второй-третий проект (или поймаем трение
в jellybit).
### Раскладка пакетов и направление зависимостей
`cmd/<bin>` (точка входа) + `internal/<компонент>` по доменам. Домен не
импортирует транспорт; зависимости направлены внутрь, к домену. Без свалок
`util`/`common`/`helpers`. Стыкуется с «тонкие транспорты, единое ядро» из
архитектуры.
### context.Context
Первый параметр функции; не хранить в структурах; в `Value` только
request-scoped данные, не зависимости. Перенос логгера/корреляции через ctx
(уже реализовано — `internal/logctx`, см. `logging.md`). Дедлайны/отмена
протягиваются сквозь стадии.
### Внешние клиенты (HTTP к зависимостям)
Таймаут на **каждый** исходящий вызов (не полагаться на дефолт); не
`http.DefaultClient`; ретраи с backoff и потолком попыток; HTTP-прокси из
конфига (`proxy`-поля уже есть). Прямое продолжение `ext.*`-логирования
(`logging.md`) и трансляции ошибок (`errors.md`). Кандидат — общий
конструктор клиента вместо копипасты в qbt/llm/jellyfin/metadata.
### Тесты
Table-driven; фикстуры в `testdata/`; `t.Parallel()` где безопасно; выбрать
и зафиксировать stdlib `testing` vs `testify`; разделение быстрых и
интеграционных (уже есть `*_integration_test.go` + env-гейты). Что считаем
обязательным к покрытию (валидация конфига, распознавание, раскладка).
## Tier 3 — тонкий бюллетень или мелочь
Не тянет на отдельный доку: строка-инвариант в `CLAUDE.md` или стек-специфика.
### БД и миграции (SQLite + goose)
Миграции forward-only; запросы только параметризованные (без склейки строк);
явные транзакции для многошаговых изменений; context-aware запросы. Сильно
стек-специфично — возможно, в `CLAUDE.md`, не в общий доку.
### Конкурентность
Каждая горутина знает, **как** останавливается (ctx/закрытие канала); без
утечек; `errgroup` для связанных задач; фоновые процессы гасятся при
shutdown. Актуально для воркера/фоновых задач, не для всего проекта.
### CLI
Данные — в `stdout`, логи и диагностика — в `stderr`; осмысленные коды
возврата. Для CLI-подмножества проектов (у jellybit — диагностические
команды `add`/`recognize`/`healthcheck`).
### Время
Явный TZ всегда; хранение и логи — в UTC; бизнес-логика в `Europe/Moscow`.
Уже частично в `CLAUDE.md` и `logging.md` — при желании свести в один
короткий инвариант.
-293
View File
@@ -1,293 +0,0 @@
# Черновик: идентичность загрузки и группировка тайтла (без сущности title)
> **Статус:** черновик-размышление (explore), не источник истины и не принятое
> решение. Начат 2026-07-01; **переработан 2026-07-02** после второго захода
> обсуждения. Когда/если решим делать — переезжает в OpenSpec change(и) и
> `docs/specs`/`docs/adr`.
>
> **Итог разбора:** отдельную сущность `title` **не вводим**. Все целевые
> сценарии решаются идентичностью загрузки (ULID + множество инфохэшей),
> **правилом сходимости папки** при раскладке и вычисляемой группировкой.
> Отвергнутые варианты и триггер пересмотра — в §7.
## 1. Зачем это
Сейчас домен идентифицирует загрузку **инфохэшем**, а целевые файлы принадлежат
**отдельной загрузке** по целевому пути. Этого хватает для базового потока, но
плохо ложится на то, что один логический тайтл (фильм/сериал) складывается из
**нескольких загрузок** во времени: сезоны, докачивание серий, перезаливы.
Калибровка по реальным болям (зафиксирована в обсуждении 2026-07-02):
- **боль:** второй сезон должен лечь в ту же папку сериала;
- **боль:** докачивание/перезалив серий (E01–10 вместо E01–05) — доложить
недостающее;
- **боль:** удалить тайтл целиком одним действием (включая опц. раздачи);
- **не боль:** апгрейд качества — из приоритета выпадает (коллизия по-прежнему
уходит в review, coexist через Jellyfin-версии доступен).
Связано с беклогом: «Идентичность загрузки: ULID + множество инфохэшей»,
«Проблема второго сезона», «Раздачи с докачиванием», «История переходов
загрузки», «Удаление средствами jellybit (path 2)».
## 2. Что уже есть (текущая модель)
```
download (INTEGER id PK, AUTOINCREMENT)
├─ source_type, source_ref, display_name, context
├─ infohash (nullable), idempotency_key (UNIQUE если NOT NULL)
├─ state, error_code/msg, source_miss_count, source_added_at
└─ created_at / updated_at
├─(1—N)→ recognition (is_current, media_type, title, year,
│ provider, provider_id, confidence, plan JSON, …)
│ └─(1—N)→ metadata_candidate (provider, provider_id, url, chosen)
├─(1—N)→ hint / override
└─(1—N)→ file_link (apply_batch_id, src_path, dst_path, kind, status)
```
Ключевые инварианты сегодня:
- **Идентичность загрузки = infohash** (`idempotency_key`), дедуп через
`FindActiveByInfohash`. Воркер сопоставляет по трём хешам (hash/v1/v2).
- **Владение целевым путём:** один `dst_path` — один владелец-`file_link`.
`SupersedeForeignLinks(downloadID, dstPaths)` при раскладке помечает
`status='superseded'` у ссылок **других** загрузок на те же пути
(last-writer-owns). Статусы: `linked|copied|exists|collision|superseded`.
- **Источник неприкосновенен**, **существующее не перезаписываем**
(`collision` → review), **откат снимает лишний хардлинк, а не последнюю
копию** (`nlink<=1` → отказ).
- **Сверка «источник × цель»** двигает рассинхрон в
`target_missing`/`orphaned`/`deleted`.
Владеют **путями**, а не «папкой сериала» — поэтому разные сезоны (разные пути)
уже сосуществуют без конфликтов, супересида между ними нет.
## 3. Что не решено сегодня
- **Сходимость папки.** Папка строится каждый раз заново из выхода
распознавания (`internal/layout/name.go`): `"Название (Год) [tmdbid-123]"`.
Совпадение `provider_id` **не гарантирует** совпадение строки папки: LLM
может дать «Fargo» и «Фарго», год сезона вместо года сериала — и второй
сезон уедет в соседнюю папку при верном матче. Это ядро «проблемы второго
сезона»: она **не про группировку, а про сходимость папки**.
- **Докачивание** — «просто новая загрузка», упирающаяся в коллизию цели →
review, без логики «доложить недостающее».
- **«Удалить сериал целиком»** — ручной сбор всех причастных загрузок.
- **Идентичность на infohash хрупкая** (v1/v2/гибрид, перезаливы) — см. §6.
## 4. Итог разбора: почему БЕЗ сущности title
Главный аргумент: **download — мост между раздачей в qBittorrent и набором
файлов на диске**, и каждая сущность цепочки отвечает на свои операции:
```
qBittorrent ──1:1── download ──владение──▶ файлы на диске
(раздача) (мост) (пути)
pause/cancel/retry FSM, ULID undo/relay, per-path
```
У `title` при разборе **не нашлось ни одной собственной операции**: сходимость
папки — правило при построении плана; merge докачивания — per-path логика;
удаление целиком — цикл по вычисляемой группе. Сущность без собственных
операций — это линза, а линзу достаточно вычислять, не хранить.
Второе: «папка — это title-уровневое состояние, ей нужен дом» (аргумент за
хранимый title) разбивается о то, что **дом у папки уже есть** — файловая
система и `dst_path` живых `file_link`'ов. Реестр дублировал бы то, что и так
записано в БД в N экземплярах. Причём вычисляемый якорь **корректнее**
хранимого: если все файлы сериала снесли, живых ссылок нет — и новая загрузка
честно создаёт свежую папку; хранимый `title.folder` указывал бы в пустоту.
Третье: отказ от сущности **устраняет** (а не решает) целый хвост развилок:
жизненный цикл тайтла (рождение/смерть/пустой тайтл), слияние тайтлов, ad-hoc
тайтл без провайдера, обратная миграция существующих строк, title-лог.
## 5. Целевая модель
Три элемента: стабильная идентичность загрузки, правило сходимости папки,
вычисляемая группировка. Плюс опциональная история переходов.
### 5.1 Идентичность: ULID + download_infohash
```
download download_infohash
id TEXT PK (ULID, генерим download_id FK→download
при приёме) infohash TEXT
…остальное как сейчас, kind v1|v2
минус idempotency_key UNIQUE(infohash) ← дедуп переезжает сюда
```
- `download.id` = ULID — публичный стабильный ключ домена; переживает
перезаливы, не завязан на хеш.
- `download_infohash` — множество хешей одной загрузки (v1/v2, в будущем —
«этот перезалив — та же загрузка»). Поиск при приёме и в поллинге — по
любому из хешей.
### 5.2 Правило сходимости папки
При построении плана раскладки для загрузки с **подтверждённым матчем**
`(provider, provider_id)`:
```
1. найти ЖИВЫЕ file_link'и (status IN linked|copied|exists) загрузок,
чей current recognition имеет тот же (provider, provider_id)
2. есть → база папки (имя+год) наследуется из существующего dst_path;
LLM-выход для папки игнорируется ← якорь
3. нет → папка из распознавания, как сейчас ← первая
загрузка «печатает» имя, остальные наследуют
```
- Это join по существующим таблицам (`file_link → download →
recognition(is_current)`), **ни одной новой сущности**.
- Правило локальное: download остаётся мостом, распознавание — недоверенным,
безопасность — на валидации пути (инварианты не трогаем).
- Человек/Jellyfin переименовал папку на диске → сверка переведёт ссылки в
`target_missing` → якорь исчезает → следующая загрузка печатает заново.
Истина — живые пути, отдельного «источника истины по папке» нет.
- Без подтверждённого матча авто-раскладки нет (инвариант) → раскладка идёт
через review, папку выбирает человек. Сходимость «без базы» не автоматизируем.
### 5.3 Вычисляемая группировка (тайтл как линза)
- «Из чего состоит сериал» = `GROUP BY (provider, provider_id)` текущих
распознаваний с живыми ссылками; эквивалентно — по общей папке в `dst_path`.
- «Удалить целиком» = перечислить загрузки группы → штатный undo каждой
(`superseded` пропускаем — путь у другого владельца; `nlink<=1` — отказ) →
опц. удалить раздачи из qBittorrent (осознанный выход за инвариант «источник
неприкосновенен», только по явному подтверждению) → опц. снести опустевшую
папку.
- На домашнем масштабе `GROUP BY` бесплатен; денормализации не нужны.
### 5.4 История переходов (опционально, дёшево)
```
state_transition (download_id, from_state, to_state, reason, actor, at)
actor ∈ {worker, human, reconcile}
```
Питает таймлайн на `/download/{id}` и метрики длительности стадий. Композиция
тайтла во времени («B долил Season 02») выводима из `download` + `file_link` +
`state_transition` — отдельный лог не нужен.
## 6. Разбор операций
### 6.1 Второй сезон
```
S1 ──lay──▶ …/Fargo (2014) [tvdbid-269613]/Season 01/… (владеет A)
S2: матч tvdb=269613 → живые ссылки A найдены → папка унаследована
S2 ──lay──▶ …/Fargo (2014) [tvdbid-269613]/Season 02/… (владеет B)
```
Пути не пересекаются → супересида нет, A не трогаем. Сходимость дало правило
§5.2, группировку — линза §5.3.
Принятая цена: если S1 заматчился через один провайдер, а S2 — через другой
(смена конфига метабаз), якорь по `(provider, provider_id)` не склеит — случай
редкий, штатно уходит в review.
### 6.2 Докачивание серий (merge)
```
существует: Season 01/E01..E05 (владеет A)
C приносит: Season 01/E01..E10 (та же папка — за счёт сходимости)
merge: E01..E05 — уже есть → не перезаписываем (владение у A)
E06..E10 — кладём (владеет C)
```
Целевая merge-логика: **доложить только недостающее**. Владение сезоном
делится между A и C по путям — нормально в per-path модели (split-ownership
принят как дефолт). Обе раздачи сидируют независимо.
### 6.3 Апгрейд качества — вне приоритета
Не боль. Коллизия на тот же `dst_path` по-прежнему → review; сосуществование
версий (Jellyfin multi-version, другой `dst`) доступно без спец-логики. Явный
replace (undo старого → lay нового → супересид) — отдельный change, если/когда
понадобится.
### 6.4 Удаление (частичное и целиком)
Частичное (одна загрузка/сезон) — уже штатный undo. Целиком — по группе §5.3.
Никакой «памяти о тайтле» после полного удаления не остаётся — и не должно
(линза без содержимого не нужна; «список того, что смотрел» — дрейф в
медиатеку, см. §7).
## 7. Отвергнутые варианты и триггер пересмотра
Разбирались и были отвергнуты (2026-07-02):
- **L2: `title` с ключом `(provider, provider_id)`** — привязывает
долгоживущую сущность к провайдеру, который может смениться.
- **L2-min: `title` со своим ULID + `title_external_id`** (провайдерные ID —
множество-атрибут, симметрично `download_infohash`). Красивая схема: решает
смену провайдера, ad-hoc тайтлы, слияние. Отвергнута потому, что у тайтла
**нет собственных операций** (§4) — все сценарии закрылись правилом
сходимости и вычисляемой группировкой, а сущность тянула жизненный цикл,
миграцию и четыре развилки.
- **L3 (title-центрично, медиатека)** — сонарр, осознанно не идём: не ходим в
индексеры, не мониторим тайтлы, не ведём профили качества, контент приносит
пользователь. См. таблицу ответственности в истории документа (git) либо
BRIEF.
**Триггер пересмотра** (чтобы не гонять этот круг заново): сущность `title`
возвращается в обсуждение, только когда появится **операция или состояние,
которому реально негде жить** в download+file_link — например, «переименовать
сериал целиком с переносом ссылок» как регулярное действие или заметки уровня
группы. До того — вычисляем.
## 8. Идентичность: ULID vs infohash (памятка)
infohash надёжен как ключ конкретной метадаты-раздачи в одном инстансе
qBittorrent, но: v1/v2/гибрид дают разные значения; перезалив/репак/докачка →
другой хеш; один логический объект → много хешей. Поэтому доменный PK — ULID,
а инфохэши — many-to-one атрибут (§5.1).
## 9. Этапность (не обязательство)
```
1. ULID загрузки + download_infohash (дедуп переезжает). ← фундамент
2. правило сходимости папки при плане раскладки. ← «второй сезон» ✓ реализовано
3. merge-раскладка (докачивание: доложить недостающее). ← §6.2
4. группа «тайтл» в UI (вычисляемая) + удаление целиком (path 2). ← §6.4
(state_transition — вставить, когда захочется таймлайн/метрики)
```
> Шаг 2 (правило сходимости папки) реализован — change
> `openspec/changes/archive/2026-07-10-series-folder-convergence/`, требования
> влиты в `openspec/specs/file-layout/`. Отличие от §5.2 черновика: живость якоря
> определяется существованием папки на диске (`os.Lstat`), а не только статусом
> ссылки; рассинхрон нескольких живых папок → review; in-app разрешение
> рассинхрона осознанно вне scope (ручной фикс на диске).
Каждый шаг — отдельный OpenSpec change; 1–2 самодостаточны и закрывают главную
боль.
## 10. Открытые вопросы (оставшиеся)
- **Несколько живых папок с одним `(provider, provider_id)`** (уже случившийся
рассинхрон до внедрения сходимости): какой якорь брать — самую свежую, самую
населённую, или отдавать в review? Скорее review: молча выбирать нехорошо.
- **Слияние загрузок при перезаливе «той же вещи»**: когда несколько инфохэшей
считать одной загрузкой (одна строка `download` + много `infohash`) vs
разными загрузками? Влияет на семантику `download_infohash` и merge §6.2.
- **Явный replace при апгрейде** — отложен целиком; вернуться, если станет
болью.
## 11. Мини-словарь (для согласованности имён)
- **Тайтл** — логический фильм/сериал; **вычисляемая группа** загрузок по
`(provider, provider_id)` / общей папке, не хранимая сущность.
- **Загрузка (download)** — один приём/раздача-вклад; свой ULID; несколько
инфохэшей; мост qBittorrent ↔ файлы.
- **Владение путём** — `file_link` отвечает за конкретный `dst_path`.
- **Супересид** — переход владения путём к более новой загрузке.
- **Сходимость папки** — наследование базы папки от живых ссылок с тем же
`(provider, provider_id)` вместо выхода LLM.
---
_Дальше по этому черновику: при желании — `opsx:propose` на шаг 1 (ULID +
download_infohash) как фундамент; шаг 2 (сходимость папки) — следующим
отдельным change._
-43
View File
@@ -1,43 +0,0 @@
# Дорожная карта
Черновик плана реализации. Ориентир, не обязательство; по ходу
уточняется. Что реализовано и как устроено — в `docs/specs`.
## Фазы
- **Ф0 — каркас.** go.mod, раскладка пакетов, загрузка TOML-конфига,
SQLite + миграции, slog-логи, `Dockerfile` (минимальный рантайм-образ,
копирует готовый бинарь), golangci-lint, lefthook. Документация (этот
этап — частично готов).
- **Ф1 — ingest + tracking (без LLM).** `Ingest()` + добавление в
qBittorrent (источник отдаём ему, категория `jellybit`, ключ
идемпотентности по infohash) + `worker`-поллинг завершения
(`savepath=/srv/media/downloads`, путь из API) + машина состояний. Наружу:
HTTP API, список в веб-UI, `jellybit add`.
- **Ф2 — распознавание.** `go-ptn` + LLM (structured output) → план +
оценка уверенности. Без записи на диск.
- **Ф3 — раскладка + минимальный review.** Хардлинки по конвенциям
Jellyfin (санитизация пути, never-overwrite), субтитры, идемпотентность,
**undo**. Авто только при матче в базе и чистой валидации; иначе → review
(htmx): подсказка + перераспознавание, из ручного — тип, выбор кандидата
базы, пометка «игнор». Полный редактор маппинга — Ф5. См.
[review-ux.md](../specs/review-ux.md).
- **Ф4 — метаданные.** TMDB/TVDB опционально (с HTTP-прокси на клиента),
provider-id в именах, валидация распознавания против числа серий.
- **Ф5 — Telegram + UX.** Бот-адаптер + парсер сообщений торрент-бота,
подтверждение в боте (карточка + кнопки + reply-подсказка, эскалация в
веб), полный редактор маппинга «файл → серия», триггер скана Jellyfin,
нотификации.
- **Ф6 — деплой.** Сборка статического бинаря здесь; доставка бинаря +
`Dockerfile` на сервер, `docker build` и запуск на месте; оркестрация —
`playbook-jellybit.yml` в umbar: общая docker-сеть, `user 1000:1000`,
mount `/srv/media` + data-том `/srv/applications/jellybit/data`,
healthcheck. Сопутствующие правки qBit (том `/srv/media`, savepath/temp
под `/srv/media`, `WebUI\ServerDomains=*`).
## Заметки по порядку
- Минимальный review-экран нужен уже в Ф3 (как только появляется режим
«спросить при сомнении»), полноценный UX — в Ф5.
- Jellyfin в umbar ещё не развёрнут — раскладку файлов это не блокирует,
тестируется без него; триггер скана подключаем, когда Jellyfin поднят.
+84
View File
@@ -0,0 +1,84 @@
# Паспорт проекта
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
устроено», [tasks/ROADMAP.md](../tasks/ROADMAP.md) — «что уже умеет», паспорт —
«зачем и для кого».
## Цель
Сократить путь «нашёл раздачу → смотрю на телевизоре» до одного действия:
кинуть торрент с парой слов контекста и получить фильм или сериал в библиотеке
Jellyfin, без ручного переименования и без каталога индексаторов.
**Потребители** — список закрытый: он определяет, что считать нужным, а что
интересным.
| Кто | Что ему нужно от нас |
| --- | --- |
| Владелец медиасервера (единственный оператор) | одна точка входа для magnet/`.torrent` + контекста; когда система не уверена — чтобы позвали, а не замяли молча; чтобы ошибку можно было откатить |
| Домашние зрители (через Jellyfin, о jellybit не знают) | правильные названия, годы, сезоны и серии — иначе Jellyfin подтянет чужие метаданные |
| Jellyfin | файлы, разложенные по его конвенциям имён, и сигнал пересканировать библиотеку, когда они изменились |
Цель достигнута, когда:
- русский контент и аниме раскладываются так же буднично, как англоязычные —
это ровно то, на чём разваливается arr-стек;
- типовое добавление не требует ни одного ручного действия после отправки
торрента, а нетиповое требует ровно одного — подтверждения в ревью;
- ошибочная раскладка откатывается одной кнопкой, не задев раздачу.
## Что целью не является
Граница домена. По ней архитектурный проход судит, не перенесено ли понятие
через границу.
- **Не индексатор и не поисковик по трекерам** (роль prowlarr). Раздачу находит
человек и приносит сам — вместе с контекстом, который он и так видит глазами.
- **Не менеджер качества релизов** (правила radarr/sonarr). Версии, репаки и
апгрейд 1080p → 2160p не отслеживаем; коллизия уходит в ревью, а не в
политику качества.
- **Не подписка на выходящие серии.** Никакого monitoring: система не ищет
ничего сама и не добавляет загрузок по своей инициативе.
- **Не торрент-клиент.** Качает qBittorrent, мы им управляем и не подменяем его
функциональность.
- **Не медиасервер.** Обложки, метаданные, учёт просмотренного и сам просмотр —
забота Jellyfin. Мы отвечаем только за то, чтобы файл лежал там, где Jellyfin
его правильно опознает.
- **Не хранилище медиа.** Данные живут в раздаче, мы создаём хардлинки и не
дублируем контент намеренно. Два исключения хранилищем нас не делают, но байты
у нас появляются: copy-fallback, когда хардлинк невозможен
([file-layout](../openspec/specs/file-layout/spec.md)), и состояние
`orphaned`, где библиотечная ссылка осталась последней копией
([state-reconciliation](../openspec/specs/state-reconciliation/spec.md)).
Сохранность мы и в этих случаях на себя не берём — резервных копий медиа у нас
нет.
- **Не мультипользовательский сервис.** Контур один, оператор один; разграничение
доступа сводится к allowlist Telegram (см. [security.md](security.md)).
## Типовые сценарии
1. **Фильм через Telegram.** Переслать боту сообщение торрент-бота → magnet и
текст сообщения становятся источником и контекстом → загрузка → распознавание
→ при подтверждённом матче в метабазе авто-раскладка → пинг «готово».
2. **Сезон сериала.** То же, но файлов много; они раскладываются сериями, а
второй сезон ложится в **ту же** папку тайтла, что и первый.
3. **Русский фильм, которого нет в базе.** Уходит в ревью: подсказка текстом и
перераспознавание, выбор источника совпадения из списка, ручной ввод id или
URL записи, предпросмотр целевых путей, «Применить».
4. **Ошиблись с привязкой.** Undo снимает наши ссылки (раздача цела) →
«Привязать заново» → правка в ревью → повторное применение.
5. **Раздачу или файлы удалили руками.** Фоновая сверка констатирует рассинхрон
(`target_missing`/`orphaned`/`deleted`), не теряя последнюю копию данных, и
лечится сама, если реальность вернулась.
## Референсы
Где смотреть prior art, когда упёрлись.
- [Jellyfin: Movies](https://jellyfin.org/docs/general/server/media/movies) и
[Shows](https://jellyfin.org/docs/general/server/media/shows) — целевые
конвенции имён, источник истины по формату, в который раскладываем.
- **arr-стек** (radarr/sonarr/prowlarr) — прежде всего как каталог того, чего мы
намеренно **не** берём; полезен по крайним случаям именования.
- **umbar** (`/home/av/projects/private/umbar`) — соседний проект того же
хозяйства: форма деплоя, раскладка `/srv`, стиль «минимум компонентов».
+41
View File
@@ -0,0 +1,41 @@
# Разведка
Наблюдения за внешним миром: что реально шлёт источник, чем документация формата
расходится с практикой и как ведут себя наши разборщики и зависимости на границе
формата. Источник истины — этот каталог, а не чужая документация.
Наблюдение о чужом **коде** живёт здесь наравне с наблюдением о чужих
**данных**, но у него есть срок годности: такая записка обязана называть версию
зависимости и условие пересмотра, потому что протухает от обновления `go.mod`, а
не от смены формата.
**Каждый вывод — с числами и командой или условиями, которыми получен**, чтобы
его можно было перепроверить. Число без провенанса проход обязан читать как
условие, а не как замер. Число, чей источник по ссылке не подтвердился, не
выбрасывается и не переписывается по догадке — остаётся с пометкой «расходится
с источником: там <что нашли>».
## Как снималось
Наблюдения снимались вручную, по ходу разработки, на домашнем контуре: реальные
сообщения торрент-бота в Telegram, реальные ответы qBittorrent WebUI API и
LLM-эндпоинта на живых раздачах. Автоматического сбора и корпуса кейсов **нет**
и не планируется: размеченный корпус решено не собирать
(`../tasks/REJECTED.md`, 2026-08-06). Числа здесь единичные и приведены как
условия, а не как статистика.
Зафиксированные образцы чужих форматов лежат прямо в тестах пакета-разборщика —
там они заодно и проверяются; где именно и почему без каталога `testdata/`, см.
[CLAUDE.md](../../CLAUDE.md) → «Запреты».
## Записи
- [torrent-bot-message.md](torrent-bot-message.md) — формат сообщения
торрент-бота, из которого приходит magnet и контекст.
- [torrent-bencode-limits.md](torrent-bencode-limits.md) — границы разбора
`.torrent` в `anacrolix/torrent`: аллокация по объявленной длине строки,
паники разбора, отсутствие «имени-заглушки». Проверено на `v1.61.0`.
- [tvdb-search-translations.md](tvdb-search-translations.md) — переводы в ответе
поиска TheTVDB v4: карта `translations`, параметр `language` как фильтр
выдачи, вырожденные значения. Сверено по swagger `4.7.10`, **живым прогоном
не подтверждено**.
+157
View File
@@ -0,0 +1,157 @@
# Границы разбора `.torrent` в `anacrolix/torrent`
Наблюдения о том, как ведёт себя библиотека разбора на **недоверенных** байтах
`.torrent`. В отличие от соседней записки про формат сообщения торрент-бота, это
наблюдение о **чужом коде**, а не о чужих данных, и потому у него есть срок
годности.
**Условие устаревания:** перепроверить при обновлении `anacrolix/torrent`.
Числа и ссылки ниже сняты на `v1.61.0` (версия зафиксирована в `go.mod`); ссылки
вида `файл:строка` относятся к ней и после бампа могут указывать не туда.
## Аллокация объявленной длины строки
`bencode` аллоцирует строку по длине, **объявленной во входе**, до того как эти
байты прочитаны: `parseString` делает `make([]byte, length)` и только потом
`io.ReadFull` (`bencode/decode.go:250` и `:258`). Ограничитель один — потолок
`DefaultDecodeMaxStrLen = 1<<27 - 1` ≈ 128 MiB (`decode.go:17`), проверяемый в
`parseStringLength` (`decode.go:223`) **до** аллокации. `metainfo.Load` создаёт
декодер, не переопределяя `MaxStrLen` (`metainfo/metainfo.go:35-37`), то есть
работает с потолком по умолчанию.
**Замер.** Вход — верхнеуровневый словарь `d7:comment<N>:xxxx`, где `<N>`
объявляет длину, а байтов за ней нет. Мерилось дельтой
`runtime.MemStats.TotalAlloc` вокруг вызова, Go 1.26.5. Программа целиком
(положить в `tmp/bencodealloc/main.go`, запустить `go run ./tmp/bencodealloc`,
каталог после замера удалить — `tmp/` в `.gitignore`):
```go
package main
import (
"bytes"
"fmt"
"runtime"
"github.com/anacrolix/torrent/metainfo"
)
func craft(declared int64, tail int) []byte {
var b bytes.Buffer
b.WriteString("d7:comment")
fmt.Fprintf(&b, "%d:", declared)
b.Write(bytes.Repeat([]byte("x"), tail))
return b.Bytes()
}
func measure(name string, data []byte) {
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
_, err := metainfo.Load(bytes.NewReader(data))
runtime.ReadMemStats(&after)
fmt.Printf("%-34s вход=%-4d байт аллоцировано=%8.2f MiB err=%v\n",
name, len(data), float64(after.TotalAlloc-before.TotalAlloc)/(1<<20), err)
}
func main() {
measure("объявлено 1 MiB", craft(1<<20, 16))
measure("объявлено 64 MiB", craft(64<<20, 16))
measure("объявлено 128 MiB - 1 (потолок)", craft(1<<27-1, 16))
measure("объявлено 128 MiB (выше потолка)", craft(1<<27, 16))
measure("объявлено 1 GiB (выше потолка)", craft(1<<30, 16))
}
```
| Объявленная длина | Размер входа | Аллоцировано | Исход |
| --- | --- | --- | --- |
| 1 MiB | 34 байта | 1.00 MiB | ошибка `unexpected EOF` |
| 64 MiB | 35 байт | 64.01 MiB | ошибка `unexpected EOF` |
| 128 MiB 1 (потолок) | 36 байт | 128.00 MiB | ошибка `unexpected EOF` |
| 128 MiB (выше потолка) | 36 байт | 0.00 MiB | ошибка `exceeds limit` |
| 1 GiB (выше потолка) | 37 байт | 0.00 MiB | ошибка `exceeds limit` |
Что из этого следует:
- **Усиление огромное, и наш лимит размера от него не защищает.** 36 байт входа
дают 128 MiB транзиентной аллокации — это ×3.7 млн, а не «крафт-8 MiB даёт
128 MiB», как предполагала исходная нить ревью. Предел приёма
`ingest.MaxTorrentSize` ([database.md](../database.md) → «Настройки с
числовым значением») стоит **до** разбора и на эту величину не влияет вовсе:
атакующему хватает трёх десятков байт.
- **Но аллокация ограничена сверху и одна на попытку разбора.** Выше потолка
библиотека отказывает, не аллоцировав ничего; ниже — аллоцирует ровно
объявленное и падает на чтении, обрывая разбор целиком. Дочитать несколько
таких строк в одном входе нельзя: первая же необеспеченная строка роняет
`Load`. Верхняя граница на один принятый `.torrent` — примерно 128 MiB, после
чего память возвращается.
- **Числа выше — это `TotalAlloc`, а не физическая память.** Разграничение
существенное, и без него вывод читается страшнее, чем есть. Деградационный
путь — `make([]byte, N)`, затем немедленно проваленный `io.ReadFull`, — до
страниц буфера **не дотрагивается**, а Linux отдаёт анонимную память
zero-fill-on-demand. Замер RSS (`/proc/self/status`, `VmRSS`) на том же
входе:
| Что мерили | VmRSS |
| --- | --- |
| 1000 одновременных разборов вырожденного входа, пик | 7.4 MiB |
| `make([]byte, 128 MiB)` без касания страниц | +0.8 MiB |
| то же с касанием одного байта | +0.02 MiB |
| то же с проходом по каждой странице | +128 MiB |
То есть N одновременных приёмов **не** дают N × 128 MiB физической памяти: до
RAM это не доходит вовсе. Формулировка «N × 128 MiB» верна только про
логический счётчик запрошенных байт кучи.
- **Отсюда вывод сильнее, а не слабее.** Пункт принят не потому, что «профиля
нагрузки нет и авось обойдётся», а потому, что **деградационный путь
структурно не расходует физическую память**. Возвращаться к нему с лимитом
параллелизма повода нет; повод появится, только если найдётся путь, на котором
объявленная строка действительно дочитывается.
- **Это вопрос устойчивости, а не безопасности.** Отказ в обслуживании изнутри
контура явно вынесен за модель угроз (`../security.md` → «Что вне модели»).
## Паники разбора не выходят наружу — но гард у нас уже́е, чем кажется
`bencode.Decoder.Decode` ловит паники разбора и возвращает их ошибкой, **кроме**
`runtime.Error` — такую он пере-паникует (`bencode/decode.go:38-46`). То есть
арифметическая ошибка или выход за границы внутри библиотеки поднялись бы
паникой через `metainfo.Load`.
Наш `recover`-гард в `internal/torrent` стоит только на `files()` и покрывает
`UpvertedFiles`/`FileTree`. Он **не** покрывает `metainfo.Load`,
`UnmarshalInfo` и `HashBytes` — они вызываются вне гарда.
Достижимого panic-пути через них найти не удалось. Проверялась гипотеза про
отрицательную объявленную длину: `parseStringLength` её пропускает
(`checkBufferedInt`, `decode.go:196-208`, принимает `-5`), и `make([]byte, -5)`
дал бы `runtime.Error`. Путь **недостижим** — диспетчер значений входит в разбор
строки только по ведущей цифре, а `-` отсекается раньше:
```
"d7:comment-5:xxxxx" → bencode: syntax error (offset: 10): unknown value type '-'
"d4:infod4:name-5:xxxxxee" → bencode: syntax error (offset: 14): unknown value type '-'
```
Это отрицательный результат, а не гарантия: он говорит, что **этот** путь
закрыт, и ничего не говорит об остальных. Расширять гард на `Load` при
обновлении библиотеки — дешёвая страховка, если появится повод.
## Библиотека не подставляет «имя-заглушку»
Смежное наблюдение о той же библиотеке, записанное потому, что его отсутствие
стоило ложной нити в ревью приёма 2026-07-08.
`metainfo.Info.BestName()` (`metainfo/info.go:200-205`) возвращает `NameUtf8`,
иначе `Name`, иначе **пустую строку**. Константа `NoName = "-"`
(`metainfo/info.go:44`) присваивается только в `BuildFromFilePath` — то есть при
**авторинге** раздачи из вырожденного пути (`.`, `..`, `/`), и в разборе не
участвует.
Значит `-` доходит до нас исключительно тогда, когда раздача **сама объявила**
его полем `name`. Это конвенция «имени нет», принятая ради совместимости с
Transmission (комментарий у константы ссылается на transmission#1775), и
библиотека экспортирует константу именно затем, чтобы на неё ссылались.
Практический вывод: «раздача без имени» и «раздача с именем `-`» — **разные**
входы, и путать их нельзя. Первый даёт пустую строку сам, второй нормализуется
нами на границе разбора (`internal/torrent`, `displayName`).
+77
View File
@@ -0,0 +1,77 @@
# Формат сообщения торрент-бота
Основной способ добавления загрузки — переслать в jellybit сообщение стороннего
торрент-бота (`exfreedomist`, поиск по rutracker и соседним трекерам). Из
сообщения берутся **источник** (magnet) и **контекст** для распознавания. Формат
чужой, ничем не документирован и может измениться без предупреждения.
**Провенанс.** Образец снят вручную из личного чата Telegram (сообщение
датировано 2026-03-21) и зафиксирован в [BRIEF.md] проекта; второй образец,
меньшего размера, живёт константой `botMessage` в
`internal/tgbot/parse_test.go` и проверяется тестами разборщика. Статистики по
вариантам формата нет — наблюдений всего два, и это условие, а не замер.
[BRIEF.md]: перенесён в [../passport.md](../passport.md); полный текст образца —
ниже и в истории git.
## Образец
```
[1] #6514485 [rutracker], 2026-03-21 (https://hashurl.ru/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…):
Дюна: Часть вторая / Dune: Part Two (Дени Вильнёв / Denis Villeneuve) [2024, США, Канада, фантастика, WEB-DL 2160p, HDR10+, Dolby Vision] Dub (Bravo Records Georgia, RHS, Jaskier, HDrezka) + MVO (LostFilm, TVShows, Jaskier) + AVO (Сербин, Яроцкий) + (Ukr) + Original (Eng) + Sub (Rus, Eng, Ukr)
✅ (проверено) | 34.82 GB
magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6&tr=http%3A%2F%2Fbt.t-ru.org%2Fann%3Fmagnet&dn=rutracker-topic-6514485
Открыть magnet в вашем клиенте (https://hashurl.ru/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…)
или получить .torrent: /tr_5c054
Оцените раздачу:
👍: /g_eabdce или 👎🏿: /r_eabdce
[список файлов] (https://download.exfreedomist.com/files/541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6)
Следить: /us_5c054
Добавить в закладки: /mka_96423
cправка: /help, index (https://exfreedomist.com/stats/)
```
## Что из этого наблюдается
- **Заголовок строки 1** — порядковый номер выдачи, `#<topic-id>`, имя трекера в
квадратных скобках, дата раздачи и ссылка-редирект `hashurl.ru` с JWT в пути.
Токен в ссылке **имеет срок жизни** (`exp` в payload) — как долгоживущий
идентификатор он не годится.
- **Строка описания** — самый ценный кусок: локализованное название, оригинальное
название через `/`, режиссёр в скобках (тоже через `/`), затем блок в
квадратных скобках `[год, страны, жанры, качество, HDR…]` и перечисление
дорожек и субтитров. Именно она уходит контекстом в распознавание.
- **Разделитель `/`** используется одновременно для пары «локализованное /
оригинальное» и для пары «имя режиссёра кириллицей / латиницей». По позиции
они не различаются — только по тому, что вторая пара стоит в скобках.
- **magnet отдельной строкой**, с `dn=rutracker-topic-<id>` — то есть `dn`
здесь **не** содержит названия фильма и как имя раздачи бесполезен.
Инфохэш в magnet — v1, uppercase hex; нормализуем в lowercase.
- **`.torrent` не приложен** — предлагается командой бота (`/tr_…`), то есть
вторым шагом в чужом чате. Поэтому источник, приходящий этим путём, —
практически всегда magnet.
- **Размер раздачи** в человекочитаемом виде (`34.82 GB`) и отметка
«✅ (проверено)».
- **Команды бота** (`/g_…`, `/r_…`, `/us_…`, `/mka_…`, `/help`) и ссылка на
список файлов — шум для распознавания, но безвредный: попадают в контекст как
есть.
- Весь текст — **недоверенный вход**: он полностью составляется чужим ботом по
данным трекера и уезжает в промпт LLM. См. [../security.md](../security.md).
## Чего не знаем
- Насколько формат стабилен: наблюдений два, оба от одного бота, разница между
ними — только в наличии строки «сохранённая копия описания раздачи».
- Как выглядит сообщение для сериала-сезонника и для аниме — образцов не
снимали, а именно на них строится самый сложный случай раскладки.
- Что бот присылает при неудачном поиске и при слишком длинном описании
(лимит Telegram на текст сообщения — 4096 символов UTF-16, по документации
Bot API `sendMessage`; сами обрезанные сообщения мы не наблюдали, поведение
бота на обрезке неизвестно).
+97
View File
@@ -0,0 +1,97 @@
# Переводы в ответе поиска TheTVDB v4
Клиент TVDB (`internal/metadata/tvdb.go`) берёт локализованное название кандидата
из ответа `/search`. Здесь записано, откуда взята форма этого ответа и чего в ней
не подтверждено.
**Провенанс — и он слабый.** Всё ниже сверено **по публичной документации**
TheTVDB API v4, файл `docs/swagger.yml` репозитория `thetvdb/v4-api`, поле
`info.version` = `4.7.10` (прочитано 2026-08-07). **Живым прогоном не
подтверждено ни одно наблюдение**: `CLAUDE.md` → «Запреты» запрещает ходить в
боевые метабазы из отладочных прогонов и расходовать лимиты ключа. Всё
дальнейшее — **условие, а не замер**. Оракул, который это закроет, написан и ждёт
человека:
```
TVDB_API_KEY=… go test ./internal/metadata/ -run Integration -v
```
Он печатает `Title` и `OriginalTitle` первых кандидатов.
## Параметр `language` у `/search` — фильтр, а не селектор перевода
Дословно из swagger, параметр `language` эндпоинта `/search`:
> Restrict results to a specific primary language. Should include the 3 character
> language code.
То есть он **сужает выдачу** по основному языку записи, а не выбирает, на каком
языке вернуть название. Форум TheTVDB подтверждает направление: маршруты
`/search` не возвращают записи, которых нет на указанном языке.
Следствие для нас: передача `language=rus` отсекла бы ровно те записи, ради
которых заводилась задача, — у `Ne Zha` основной язык `zho`. Поэтому запрос
поиска параметром языка **не параметризуется**, а локаль работает только на
стороне разбора ответа. Это заказано спекой (`openspec/specs/metadata-match/`,
требование «Локализованное название кандидата TVDB»).
Расхождение с TMDB намеренное: у TMDB `language` — именно селектор
локализованного поля, и там он в запрос уходит.
## Поля `SearchResult`, относящиеся к названию
Из схемы `SearchResult` того же swagger:
| Поле | Тип по схеме | Что берём |
|---|---|---|
| `name` | `string` | primary name записи — идёт в `Candidate.OriginalTitle` и служит фолбэком для `Title` |
| `translations` | `TranslationSimple` | карта «код языка → название»; из неё берём `Candidate.Title` |
| `name_translated` | `string` | **не используем** |
| `overviews`, `overview_translated` | описания | не используем |
| `primary_language` | `string` | не используем |
| `translationsWithLang` | массив строк | не используем |
`TranslationSimple` в самом файле swagger описан как открытая карта (свободные
ключи со строковыми значениями); **полного текста этой схемы вычитать не
удалось** — документ в местах чтения обрывался. Форма «карта кода языка в
строку» принята по описанию поля и по обсуждениям в трекере `thetvdb/v4-api`,
где встречаются фрагменты вида `"translations": {"eng": "…"}`. Это самое слабое
место записки: если реальная форма иная (список объектов, двухбуквенные ключи),
разбор молча уйдёт в фолбэк.
**Почему не `name_translated`.** Семантика поля в документации не описана
вовсе — не сказано ни на каком языке оно приходит, ни от чего зависит.
Правдоподобно, что заполняет его поисковый индекс при заданном фильтре
`language`, которого мы не шлём. Взять его в фолбэк значило бы получить название
на неизвестном языке молча; карта `translations` самодостаточна.
## Что известно про вырожденные значения
В трекере `thetvdb/v4-api` есть подтверждённый случай, когда `name` приезжает
**пустой строкой** при непустом блоке переводов (issue про `"name":"" must not be
empty`). Отсюда два следствия для разбора, оба заказаны спекой:
- пустая строка в этих полях реальна, поэтому пустота значения перевода
проверяется после обрезки пробелов;
- блок переводов может нести ключ с пустым значением — это не «перевод есть».
## Как мы защищаемся от того, что запись неверна
Наблюдение не подтверждено, поэтому разбор устроен так, чтобы ошибка записки
стоила как можно меньше:
- блок переводов разбирается **отдельно от остального ответа** и его негодная
форма гасится в фолбэк: косметическое поле не получает права уронить выдачу
поиска целиком;
- ключ ищется регистронезависимо;
- если в выдаче не разобрался **ни один** блок переводов, клиент пишет строку
DEBUG. Это единственный сигнал, отличающий «форма ответа не та, что здесь
записана» от штатного «перевода на этот язык нет»: без него неверное
предположение жило бы в бою неограниченно долго при зелёном гейте.
## Условие пересмотра
Записка протухает от смены версии API TheTVDB (сегодня v4, swagger 4.7.10) и от
любого ручного прогона интеграционного теста: первый же живой ответ обязан
заменить здесь предположения на наблюдения, а слова «живым прогоном не
подтверждено» — на дату и результат прогона.
+543
View File
@@ -0,0 +1,543 @@
# Ревью: настройка и журнал
Проектная часть конвейера ревью: чем jellybit отличается от абстрактного
Go-сервиса и что здесь уже проскакивало. Устройство самого конвейера (метки,
стадии, контракт находок) живёт в скилле, а не здесь.
## Как настроен конвейер
**Прежде чем задать вопрос, посмотри, не задан ли он уже машиной.** Перечень
механизированного — [conventions/README.md](conventions/README.md) →
«Механизировано»; чем безусловно краснеет гейт и чего в нём намеренно нет —
[CLAUDE.md](../CLAUDE.md) → «Гейт». Вопрос про уже проверенное вытесняет вопрос
про непроверенное — места в прогоне столько же.
**В worktree гейт краснеет ложно, и это не находка.** Ветки задач живут в
`tmp/wt-<задача>` — внутри самого репозитория. Два следствия, оба наблюдались:
- кэш `golangci-lint` переживает смену каталога и отдаёт результаты прошлого
прогона из **основного** дерева. Признак — пути в `tmp/gate/lint.log`
начинаются с `../../internal/`, то есть указывают наружу worktree, и жалобы
приходят на файлы, которых дифф не касался. Лечится
`golangci-lint cache clean` перед прогоном;
- пробы проходов ревью, оставленные в `tmp/`, линтуются вместе с проектом:
`.go`-файл со `fmt.Printf` в `tmp/` краснит шаг `lint` через `forbidigo`.
Проход обязан за собой убирать, а оркестратор — сверять `tmp/` перед гейтом.
**Severity не выводится проходом заново.** Она стоит рядом с формулировкой
инварианта в [CLAUDE.md](../CLAUDE.md) → «Инварианты», обратимость — там же в
«Работа» → «Необратимое». Шкала ущерба берётся оттуда, а порядок ценностей —
из [security.md](security.md) → «Что чувствительнее чего».
### Типовые узлы
Рода узлов проекта и проверяемые свойства к каждому. Род, а не инвентарь
пакетов: узел, которого ещё нет, но который проект заведёт, включён намеренно.
**Клиент внешнего HTTP-сервиса** (`qbt`, `llm`, `metadata`, `jellyfin`, `tgbot`)
- у каждого исходящего вызова свой таймаут из конфига, не дефолт транспорта;
- `context` доходит до запроса и отменяет его, а не игнорируется;
- ошибка зависимости отличима от ошибки нашей логики на приёме результата;
- секреты (пароль, ключ, токен) не попадают ни в лог, ни в текст ошибки;
- недоступность **опциональной** зависимости (метабаза, Jellyfin, Telegram) не
двигает состояние загрузки и не краснеет ERROR-ом в фоновом цикле.
**Тик воркера и переход состояния**
- переход легален по декларативному графу, а не «просто присвоили `state`»;
- работа идёт под per-download блокировкой; два транспорта не гонятся;
- тик идемпотентен: повтор на том же состоянии не порождает второго эффекта;
- отмена контекста на середине не оставляет полуприменённого состояния;
- новое промежуточное состояние имеет выход **и** предохранитель по времени.
**Репозиторий `store`**
- запрос параметризован, время только через `store.Now()`, id через `ident`;
- многошаговое изменение — в одной write-транзакции (`_txlock=immediate`);
- инвариант, не выражаемый схемой («одна активная загрузка на infohash»),
держится guarded-методом, а не проверкой в вызывающем коде;
- миграция forward-only и сопровождается правкой [database.md](database.md).
**Операция с файловой системой** (`layout`)
- целевой путь проверяется **после** `filepath.Clean`, на принадлежность
библиотеке;
- существующая цель не перезаписывается ни при каком исходе;
- под `paths.downloads` нет ни одной операции записи или удаления;
- частичный сбой батча оставляет систему в состоянии, из которого повтор
доводит начатое или откатывает целиком;
- удаление снимает только свои ссылки своего батча и не снимает последнюю копию.
**Парсер недоверенного входа** (`magnet`, `torrent`, парсер сообщения бота,
разбор ответа LLM)
- вход враждебный по умолчанию: длина, вложенность, мусорные байты, пустота;
- разбор не паникует и не аллоцирует по числу из самого входа;
- невалидный вход даёт доменную ошибку, а не тихий дефолт;
- результат нормализуется на границе (lowercase hex, trim, `ident.Parse`).
**Вызов LLM и разбор его ответа** (`recognize`, `naming.Derive`)
Самый специфичный род узла в проекте: единственный, чей выход недетерминирован,
недоверен и стоит денег одновременно.
- решение узла **не является гейтом безопасности**: инъекция в промпт считается
состоявшейся, защита стоит ниже — на валидации целевого пути
([security.md](security.md));
- самооценка модели не заменяет матч в метабазе — порог `confidence` стоит
**поверх** матча дополнительным условием, а не вместо него;
- разбор ответа устойчив к лишним и недостающим полям, обрезке и не-JSON;
негодный ответ даёт доменную ошибку, а не тихий дефолт;
- попытка стоит лимита и денег: число ретраев берётся из конфига, а фоновый цикл
не запускает распознавание сам по себе повторно;
- тест не ходит в живой эндпоинт — провайдер за интерфейсом, ответ фикстурой;
живые прогоны только за env-гейтом.
**htmx-хендлер**
- один партиал обслуживает страницу и фрагмент, ветвление по `isHTMX`;
- ошибка на htmx-пути — 200 плюс фрагмент, а не 4xx/5xx;
- страница деградирует без JS;
- поллинг самозавершается, когда наблюдать больше нечего.
### Типовые ложноположительные
Находки, которые здесь выглядят убедительно и всегда неверны.
- **«Веб-UI и REST без авторизации».** Принятое решение под сегодняшний
периметр — [security.md](security.md). Дефектом станет только вместе с путём
снаружи LAN.
- **«Ошибка на htmx-пути возвращает 200».** Так и задумано —
[conventions/web-ui.md](conventions/web-ui.md).
- **«Решение auto/review должно опираться на `confidence` модели».** Неверно в
форме «вместо матча»: авто только при подтверждённом матче в базе —
[ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md).
Самооценка LLM плохо откалибрована и поддаётся инъекции. Порог `confidence`
при этом существует **дополнительным** блокирующим условием поверх матча —
его наличие дефектом не является.
- **«Копировать надёжнее, чем хардлинк» / «взять симлинк».** Хардлинк —
осознанный выбор ради неприкосновенности источника и недублирования диска,
[ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md); copy — только
фолбэк.
- **«Целочисленный автоинкрементный ключ был бы проще».** ULID — требование
capability `identity`; `AUTOINCREMENT` вдобавок краснит гейт.
- **«Не хватает метрик, трейсинга, health-эндпоинтов по каждой зависимости».**
«Минимум компонентов» — принцип проекта; глубокий healthcheck заведён задачей
и ждёт своей очереди, а не является упущением.
- **«Здесь нужен интерфейс, чтобы это можно было замокать».** Единственная
реализация за интерфейсом — обычно лишний слой; см. «Честный предел» ниже.
- **«Нет ретрая у вызова в фоновом цикле».** Тик повторится сам через
`poll_interval`; ретрай внутри тика чаще вреден.
- **«Оригинальное и локализованное названия дублируются — избыточность».**
`original_title` заполняется всегда и при неуверенности дублирует `title`
это контракт capability `recognition`, а не недосмотр.
### Вопросы по темам
Форма: `<тема>: <вопрос> (<провенанс>)`. Адресуется теме, а не имени прохода:
проход переезжает между метками и упраздняется, тема переезд переживает. Задаёт
вопрос тот, кто закрывает тему на текущем прогоне.
- `security`: можно ли, управляя только именами файлов в раздаче и текстом
контекста, добиться целевого пути вне `paths.movies`/`series` — включая путь
через юникод, длину сверх лимита ФС и коллизию после нормализации?
(инвариант «целевой путь строго под библиотекой», [security.md](security.md))
- `security`: есть ли последовательность команд, после которой снимается
**последняя** копия данных — с учётом `superseded`-ссылок и гонки со сверкой?
(инвариант «источник неприкосновенен»)
- `security`: что даёт крафт-магнет с чужим или подставным инфохэшем —
присоединение к чужой активной загрузке, отравление владения?
(открытая задача про идентичность инфохэшей)
- `security`: где признак «это наше» снимается с одной сущности, а действие
применяется к другой — присутствие раздачи в qBittorrent против байтов на
диске, запись в БД против файла, инфохэш против содержимого? (журнал,
2026-08-06: уборка своего торрента сносила чужие файлы)
- `operations`: что делает эта ветка, когда qBittorrent недоступен несколько
минут подряд — сколько ERROR-строк в секунду и меняется ли состояние задач?
(задача про ERROR-шторм фоновых циклов)
- `operations`: как это ведёт себя при сотне загрузок в базе и десятках тысяч
`file_link` — есть ли запрос без индекса и полный проход по таблице?
(задача про масштаб 100/1000, [database.md](database.md) → «Настройки»)
- `operations`: что остаётся на диске и в базе, если процесс убит посреди
раскладки батча? (состояние `linking` и его восстановление)
- `autotests`: изменённые строки не просто исполнены тестом, а **проверены**
есть ли тест, который упал бы без этой правки? (diff-coverage в гейте меряет
исполнение, отличить его от проверки машина не может)
- `autotests`: конкурентная часть правки проверена тестом, а не рассуждением?
(`-race` без gcc уходит в `SKIP`, и тогда гонки не проверял никто —
[CLAUDE.md](../CLAUDE.md) → «Гейт»)
- `autotests`: новый тест не ходит в живой qBittorrent, LLM, метабазу или
Telegram — а если ходит, он за env-гейтом в `*_integration_test.go`?
([CLAUDE.md](../CLAUDE.md) → «Запреты»)
- `conventions`: логирующий чекпоинт один на операцию — или ошибка залогирована
и возвращена вверх, где залогирована снова?
([conventions/logging.md](conventions/logging.md))
- `conventions`: новое поле конфига появилось в `config.example.toml` с
описанием назначения, диапазона и единиц?
([conventions/config.md](conventions/config.md))
- `requirements`: не завелось ли поведение, которого спека не заказывала — тихий
дефолт, проглоченная ошибка, ретрай «на всякий случай», отброшенное поле?
- `security`: читается ли тело ответа внешнего сервиса целиком без предела —
у LLM (8 MiB) и метабаз (4 MiB) предел стоит, и новый исходящий вызов обязан
заводить свой ([security.md](security.md) → «Что вне модели»)
- `operations`: гарантия, которую вводит изменение, поставлена на запись или на
чтение — и что будет с данными, записанными до деплоя, которые обычный путь
не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и
очередь ревью её обходила)
- `operations`: новая проверка встала в общую точку — что она отняла у тех, кто
зовёт эту точку не ради проверки? Пропала ли команда, стал ли предпросмотр пустым, перестал ли экран объяснять
причину, и узнает ли человек причину на **каждом** входе, а не только
на том, который разбирали? (журнал, 2026-08-10: проверка длины погасила
«Применить» и ничего не объяснила)
- `operations`: не удваивает ли новая ветка расход лимита метабаз и платного
LLM — повтор, ретрай, «распознать заново» на том же входе? (кэша ответов нет,
задача `metadata-cache`)
- `operations`: копится ли то, что заводит это изменение, без срока хранения —
строки терминальных задач, сырые ответы, файлы? (авточистки в проекте нет,
задача `db-retention-cleanup`)
- `architecture`: не появился ли второй способ делать то, что уже делается —
второе место, где генерится время или id, второй парсер источника, вторая
логика целевых имён мимо `naming`?
([architecture.md](architecture.md) → «Единые точки проекта»)
### Триггеры метки
Уточняет умолчания конвейера, не отменяет их. Рабочее умолчание — `medium`;
миграция схемы и публичный контракт метку **не** поднимают: их проверяют
проходы, которые в `medium` и так есть. Списка три: два поднимают до `large`,
по одному на ось, третий опускает до `small`.
**Крупное здесь** — про объём, сколько узлов и слоёв трогает изменение:
- перенос ответственности между `worker`, `recognition`, `layout` и `store`;
- новое состояние в графе переходов загрузки: оно тянет за собой воркер, спеку,
отображение в веб-UI и боте и восстановление после рестарта;
- правка, идущая насквозь по цепочке приём → распознавание → раскладка;
- новый провайдер метабазы за существующим интерфейсом: клиент, поле конфига с
образцом, слияние полей кандидата, ветка «провайдера нет».
**Незнакомое здесь** — про форму решения, которую предстоит нащупать по ходу:
- новый пакет `internal/*` или новая capability в `openspec/specs/`;
- новый транспорт приёма или уведомлений рядом с REST, веб-UI, ботом и CLI;
- заводится или меняется **правило идентичности, слияния или разбора**: ключ
владения раздачей и сверка с qBittorrent (`ident`, `internal/store`,
`state-reconciliation`); построение целевых путей и санитизация имён
(`internal/layout`, `naming`); новый вид входа или новая ветка неоднозначности
у разбора недоверенного — bencode, magnet, текст контекста, ответ LLM
(`internal/torrent`, `internal/magnet`, `internal/tgbot/parse.go`); новый
источник или новый победитель при конфликте в слиянии кандидата метабазы
(`internal/metadata`, `metadata-match`); merge-раскладка при повторном
добавлении раздачи.
- **новая поверхность поверх необратимой операции** — вторая точка входа в
команду, которая удаляет файлы или снимает последнюю копию. Форма решения
здесь нащупывается по ходу: подтверждение, порядок отказов, остаток,
наблюдаемость. Выведено по факту на `bulk-delete-page` (журнал, 2026-08-10):
метка `medium` не дала ни враждебного прохода, ни замера, а именно они нашли
четыре дефекта класса «необратимо».
- «Поведение, видимое снаружи» здесь включает **тексты и карточки Telegram**
для единственного пользователя это и есть интерфейс.
**Место из перечня метку не поднимает — поднимает правило.** Перечни выше
отвечают «здесь такие правила водятся», а не «любая правка здесь идёт в
`large`». Метку поднимает то, что даёт работу новому проходу: заводится ключ
сравнения или меняется его состав; у разбора появляется новый вид входа или
новая ветка неоднозначности; в слияние добавляется источник или меняется
победитель при конфликте.
**Мелкое здесь** — опускает до `small`. Перечень закрытый, каждый пункт
проверяется взглядом на дифф и дельта-спеку, любое сработавшее держит метку
внизу:
- **дельта-спека называет исход поимённо** — сценарий уже говорит, что даёт
вырожденный вход, и решать в коде нечего;
- **новых сценариев в дельта-спеке нет** — изменение уточняет уже описанное
поведение, а не заказывает новое;
- **правка сообщения, комментария, записи журнала, имени или теста** в узле из
перечней выше;
- **сужение уже существующей нормализации** без нового вида входа: вход остался
тот же, изменился исход на одном его значении.
Отрицательный тест поверх перечня: что после мерджа не откатывается обратной
правкой — миграция, формат на диске, публичный контракт, имя, — **не** `small`,
каким бы маленьким ни был дифф.
**Ориентир частоты.** `medium` закрывает большинство задач, `large` рассчитана
на 5–10% и приходится на крупную функциональность, а не на уборку: задача типа
`chore` или `fix`, собранная из нитей прошлого ревью, идёт в `small` или
`medium`, даже когда трогает файл из перечней выше. `large` чаще одной задачи из
десяти означает ошибку в критерии, а не полосу сложных задач подряд.
### Недоступно проверке
**Не проверит ни один проход** — принципиальная граница, по факту промаха не
пересматривается.
- `operations`: история инцидентов на umbar и то, что уже ломалось в проде.
- `operations`: поведение таблицы SQLite под реальным объёмом и профилем
нагрузки — реального профиля нет ни у кого, кроме сервера.
- `architecture`: завязка внешних потребителей (Jellyfin, закладки, чужие
ссылки) на текущее поведение.
- `architecture`: суждение «этой функциональности не должно существовать».
- `requirements`: качество распознавания как таковое — правильно ли LLM
определил фильм. Это вопрос тюнинга модели и промпта, а не ревью кода;
размеченный корпус, по которому это можно было бы судить числом, решено не
собирать (`tasks/REJECTED.md`, 2026-08-06).
**Перестали проверять сознательно** — пересматривается первым, как только
что-то проскочило.
- `conventions`: **идиоматичность Go — с 2026-08-04.** Проектный проход `idiom`
(поимённая сверка с положениями Effective Go, Go Code Review Comments,
стайлгайдов Uber и Google) упразднён вместе с переездом конвейера в плагин
([ADR-2026-08-04-review-pipeline-to-plugin](adr/ADR-2026-08-04-review-pipeline-to-plugin.md));
способные части переселены — в тему `operations` (эксперимент против
поведения библиотеки и драйвера) и в `architecture` («не изобретаем ли то,
что уже есть в библиотеке»). **Различение «идиоматично против
распространено» теперь не спрашивает никто.** Класс обратимый: портит форму
кода, не данные. Пересмотр — задача `quality-review-agents`.
- `security`, `operations`, `architecture`: на метках `small` и `medium` не
проверяется ничто, требующее запуска, — построенных путей атаки, замеров и
эксплуатационного постмортема там нет по устройству конвейера. Их даёт только
`large`, а она приходится на 5–10% задач.
## Журнал дефектов
Запись на каждый воспроизведённый дефект, **сразу**, а не ретроспективно: со
временем теряется не факт, а причина непоймания. Проскочившие — эвал-сет для
калибровки конвейера, выборка по пометке.
Форма:
<!-- копия: журнал-дефектов-форма из av-dev-pipeline/skills/review-pipeline/references/review-journal.md -->
## ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман]
- **Где:** путь:строка либо «конвейер, а не код»
- **Симптом:** как обнаружилось, кем и когда
- **Причина:** что на самом деле было не так
- **Чем воспроизведён:** тест, команда, замер — с числами
- **Почему не поймали:** только для проскочивших — какой проход обязан был найти
и что ему помешало
- **Что меняем:** правило прохода, шаг гейта, конвенция, факт в документе
проекта — либо «ничего, цена поимки выше цены дефекта»
<!-- /копия: журнал-дефектов-форма -->
### Записи
Журнал заведён 2026-07-23 вместе с переработкой конвейера
([ADR-2026-07-23-review-pipeline-generative](adr/ADR-2026-07-23-review-pipeline-generative.md));
случаи до этой даты не восстанавливались — восстановленная постфактум причина
непоймания недостоверна, а именно она и нужна.
## 2026-08-10 — метка занижена: новая поверхность поверх необратимой операции прошла как среднее знакомое [пойман]
- **Где:** конвейер, а не код — разметка задачи `bulk-delete-page`
- **Симптом:** прогон по метке `medium` закончился шестью находками, из них ни
одной про необратимое. Сигнал «метка, вероятно, занижена» вернули два прохода
из четырёх — `code` и `basics`, с одинаковым основанием: дифф трогает `store`,
`worker`, `httpapi`, `tgbot` и шаблоны и заводит новую точку входа поверх
команды, удаляющей файлы
- **Причина:** обе оси считались по объёму и по знакомости узлов, и по ним
изменение честно выходило средним и знакомым — цикл над готовым `Delete`. Ни
один триггер `large` не описывал случай «поверхность новая, а операция за ней
необратимая»
- **Чем воспроизведён:** повторная разметка после правок дельта-спек вернула
`large`; догнанные проходы дали четыре находки класса «необратимо», из них три
с прогнанными падающими тестами (`TestAdversaryDoneRowIsLastCopyWithoutWarning`,
`TestAdversaryDeleteWipesSourceOfAnotherActiveDownload`) и одна с замером
удержания общего замка воркера (`280.450631ms` при задержке соседа `300ms`)
- **Что меняем:** в «Триггеры метки», ось «незнакомое», добавлен пункт про новую
поверхность поверх необратимой операции
## 2026-08-10 — три дефекта поштучного удаления жили незамеченными, пока рядом не появилась пачка [проскочил]
- **Где:** `internal/worker/review.go` (`Delete`), `internal/worker/worker.go`
(`Poll`)
- **Симптом:** враждебный и эксплуатационный проходы на задаче
`bulk-delete-page` нашли три дефекта, ни один из которых эта задача не
вносила: удаление сносит раздачу, которой владеет **другая активная** загрузка
с тем же инфохэшем; удаление держит общий замок воркера через сетевой вызов и
останавливает фоновую работу на это время; при недоступном qBittorrent задача остаётся `done` весь простой
соседа, потому что сверка возвращается на первой же ошибке и до коррекции не
доходит
- **Причина:** поштучное удаление ни разу не проверялось меткой `large` — ни
построенного пути, ни замера против него не гонял никто
- **Чем воспроизведён:** тесты и замеры перечислены в
`openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md`,
находки 24
- **Почему не поймали:** проходы, находящие этот класс, живут в метке `large`, а
задачи, заводившие и правившие `Delete`, шли ниже. Дефект не «пропустил
проход» — проход не запускался
- **Что меняем:** три записи в беклоге со ссылкой на оракулы; триггер метки
дополнен (см. запись выше), чтобы следующая поверхность над необратимой
операцией шла сразу с доказательными проходами
## 2026-08-10 — тест остался зелёным навсегда, потому что проверял снятый атрибут [пойман]
- **Где:** `internal/httpapi/live_test.go``TestFragProgressStopsWhenNotDownloading`
- **Симптом:** проход `autotests` на ревью кода change `card-live-refresh` заметил,
что тест «фрагмент отдаётся без атрибутов поллинга» больше не может упасть
- **Причина:** change снял `hx-get`/`hx-trigger` с партиала `progress`
**безусловно**, а тест утверждал их отсутствие только для завершённой задачи.
Утверждение стало истинным при любом входе — тест перестал проверять что-либо,
оставаясь в дереве как доказательство поведения
- **Чем воспроизведён:** `git diff` шаблона против тела теста; проверка инверсией
невозможна по построению — сломать реализацию так, чтобы тест покраснел, нечем
- **Что меняем:** ничего в гейте. `diff-coverage` меряет **исполнение**, а не
проверку, и такой класс не видит по устройству — 24/24 строк были покрыты при
зелёном тесте-пустышке. Ловится либо мутационным прогоном (в гейт не заводим:
цена выше пользы на нынешнем объёме), либо тем же вопросом темы `autotests`
(«есть ли тест, который упал бы без этой правки») — он и сработал. Тест
переформулирован на то, что теперь является предметом: вне `downloading` блок
живых цифр не рисуется вовсе
## 2026-08-10 — проверка встала в общую точку и погасила кнопку, ничего не объяснив [пойман]
- **Где:** `internal/layout/layout.go``BuildLinks`; `internal/worker/review.go`
— сборка предпросмотра; `web/templates/partials/review_main.html` — панель
действий. Норма — `openspec/specs/review/spec.md`, требование «Панель действий
при пустом предпросмотре называет причину».
- **Симптом:** новая проверка длины целевого имени встала в `BuildLinks`
единственную сборку пути, откуда строятся и оба предпросмотра ревью. Отказ
обнулил предпросмотр, а без него экран прячет команду «Применить». Текст
причины брался из `error_msg` последнего перехода, но на самом частом входе
(распознавание без матча) это поле пусто: задача приходит в `review` без
причины и до раскладки не доходит. Владелец видел «Подтверди источник» и не
видел ни кнопки, ни настоящей причины. **До изменения** предпросмотр строился,
кнопка была, и применение доводило хотя бы до `failed` с текстом ядра.
- **Почему не поймали раньше:** обе стороны выглядели верными по отдельности.
Проверка в общей точке — правильное решение, оно и дало совпадение показанного
с применённым. Текст из `error_msg` — тоже правильное, для того входа, который
разбирали на чекпоинте (ручное «Применить» причину записывает). Развилка была в
том, что вход не один, и второй — частотнее.
- **Чем ловится теперь:** причина считается на показе
([ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md)),
тесты `TestReviewData_PreviewErrorWhenStateHasNoReason`,
`TestActionBarNamesReasonWithoutPreview`, `TestReviewCard_NamesPreviewProblem`.
В вопросы темы `operations` добавлен вопрос про сужение видимого.
## 2026-08-10 — спека нормировала случай, которого код произвести не может [пойман]
- **Где:** дельта `openspec/specs/file-layout/spec.md`, сценарий про
унаследованную от папки-якоря базу.
- **Симптом:** на чекпоинте ревью дизайна был назван тупик — при живом якоре база
берётся с диска, подсказка её не укорачивает, задача циклится. Сценарий уехал в
спеку с инструкцией человеку («выбрать источник без базы либо переименовать
папку руками»). Попытка написать под него тест показала, что случай
**недостижим**: папка-якорь лежит на диске и потому уже не длиннее предела, а
хвост имени файла (`" S02E01"` плюс расширение, 11 байт) не длиннее хвоста
имени папки (`" ["` плюс provider-тег плюс `"]"`, минимум 11 байт).
- **Почему не поймали раньше:** случай звучал правдоподобно и опирался на верное
свойство (унаследованная база подсказкой не меняется). Ни один проход ревью
дизайна арифметику не считал — кода на той стадии нет, а сценарий выглядел как
описание существующего поведения, а не как гипотеза.
- **Чем ловится теперь:** сценарий переписан на верное утверждение, свойство
закреплено тестом `TestApply_InheritedBaseAtLimitStillFits` — он покраснеет,
если суффиксы имён вырастут. Урок общий: **сценарий, который нельзя
воспроизвести, обязан получить оракул до того, как попадёт в спеку**; норма,
описывающая недостижимое, не отличается от неверной.
## 2026-08-10 — чистка названия метабазы стояла только на записи, и очередь ревью её обходила [пойман]
- **Где:** `internal/worker/review.go``sourcePins`, `applyOverrides`,
`buildSources`. Норма — `openspec/specs/metadata-match/spec.md`, требование
«Санитайзинг названий кандидатов, уходящих в ревью», и
`openspec/specs/review/spec.md`, «Подтверждение матча обновляет отображаемое
имя».
- **Симптом:** найден на ревью самой задачи `metadata-title-sanitize`, до
мерджа. В эксплуатации не всплывал. Сошлись независимо четыре прохода:
`adversary` (построенный путь с падающим тестом), `ops` (постмортем),
`specs` и `code`.
- **Причина:** правка чистила значение метабазы **в момент записи** — при
копировании кандидата в список для ревью. Из этого следовали три дыры разом.
(1) Кандидаты, сохранённые прежними версиями, лежат в хранилище грязными, а
их выбор человеком закреплял название дословно. (2) `applyOverrides` читал
значение, закреплённое до деплоя, дословно, и обычное «Применить» без
повторного выбора источника создавало ровно тот каталог, ради которого
правка затевалась. (3) Предпросмотр источника на экране считался из сырого
названия, а гейт пригодности стоял только на закреплении — экран показывал
одно, раскладка делала другое, при том что «превью = применение» записано
требованием `web-ui`.
- **Чем воспроизведён:** тремя тестами, каждый падает без правки (проверено
прогоном с временно снятой правкой): `TestBuildSources_PreviewMatchesApply`,
`TestChooseCandidate_DirtyLegacyTitleSanitized`,
`TestApplyOverrides_LegacyDirtyPinSanitized`. Плюс прогон `adversary`:
превью `- (2014) [tvdbid-269613]/…` против применяемого
`Догадка (2014) [tvdbid-269613]/…` на одном экране.
- **Почему не поймали:** ловить было нечему — дефект поймали на этом же ревью,
до мерджа. Записывается ради причины его появления: **гарантия, поставленная
на запись, молчаливо не распространяется на данные, записанные раньше.**
Ревью дизайна дошло до «закрыть оба пути подтверждения матча», но точкой
закрытия выбрало запись, а не чтение; вопрос «а что с тем, что уже лежит в
хранилище» не задал никто из трёх проходов стадии дизайна. Его задал
эксплуатационный проход — на оси времени, где он и живёт.
- **Что меняем:** вопрос темы `operations` (ниже) — про гарантию, поставленную
на запись. Решение по существу — ADR-2026-08-10-sanitize-at-every-entry:
чистка стоит на каждой точке входа, включая чтение.
## 2026-08-06 — уборка своего торрента после отмены сносит чужие файлы [проскочил]
- **Где:** `internal/worker/worker.go:501-556` — гард `:501-509`, удаление
`:550`. Норма — `openspec/specs/download-tracking/spec.md`, требование
«Добавление пойманной загрузки в qBittorrent».
- **Симптом:** найден проходом `adversary` на ревью задачи
`dismiss-marker-lost` (2026-08-06), не в эксплуатации. В проде не всплывал.
- **Причина:** гард «подтверждённое отсутствие непосредственно перед `add`»
подтверждает отсутствие **записи торрента** в qBittorrent, но не отсутствие
**данных** на диске. Пользователь, снявший раздачу из qBittorrent с
сохранением файлов (`download-tracking` сама предписывает это как способ
восстановления зависшей magnet-раздачи), и подавший тот же торрент заново,
получает `add`, подхватывающий пред-существующие файлы. Отмена в окне между
re-read и `PromoteCatched` даёт `torrents/delete` с `deleteFiles=true` по
этим файлам. Исключение инварианта «источник неприкосновенен» покрывает
«собственный торрент», а признак «своё» подменён на «торрента не было».
- **Чем воспроизведён:** тестом на фейковом клиенте qBittorrent во временном
каталоге прогона (`tmp/`, не сохранён): `Cancel` в окне после `add` вызывает
`Delete(hashes, deleteFiles=true)` при живом файле под `paths.downloads`,
созданном до `add`. Тот же путь достижим через `Dismiss` — гейт `Dismiss`
шире, а уборка срабатывает по состоянию (`after.State != catched`), а не по
команде. **Не прогонялся** последний шаг — что боевой qBittorrent по
`deleteFiles=true` физически сносит пред-существующий контент: в бой ходить
запрещено, отсюда `Confidence: medium`.
- **Почему не поймали:** окно после `add` разбиралось как **гонка** (кто
успел — отмена или промоушен) и проверялось на «не удалим ли чужой торрент».
Вопрос «а если торрента нет, но данные есть» не задавал никто: ни один
проход не спрашивал про **асимметрию признака владения** — признак снимается
с одной сущности (запись в qBittorrent), а действие применяется к другой
(байты на диске). Враждебный проход до этой задачи на данном коде не гонялся.
- **Что меняем:** в «Вопросы по темам» добавлен вопрос темы `security` про
асимметрию признака владения. Сам дефект — задачей в беклоге, кандидат
`critical`; спека `state-reconciliation` в том же изменении перестала
утверждать, что уборка «данных пользователя не касается».
## 2026-08-06 — нормализация имени раздачи сама производила сентинел, который отбрасывала [пойман]
- **Где:** `internal/torrent/torrent.go`, функция `displayName` (введена
изменением `ingest-nits`). Норма — `openspec/specs/ingest/spec.md`, требование
«Вырожденное имя раздачи не считается именем».
- **Симптом:** найден на ревью изменения, до мерджа. В эксплуатации не был.
- **Причина:** сравнение с `metainfo.NoName` стояло **до** схлопывания
пробельного. Имя `" - "` сравнение не проходило, а `oneLine` превращал его
ровно в `-`, и вырожденное значение уезжало вниз по потоку всеми тремя
путями: строкой названия в контексте распознавания, в `source_ref` (фолбек на
имя присланного файла не срабатывал — строка непуста) и подсказкой вывода
отображаемого имени. Против `master` это **регрессия**: там стояло
`strings.TrimSpace(...)` перед сравнением с `-`, и фолбек работал.
- **Чем воспроизведён:** двумя независимыми падающими тестами — `adversary`
прогнал приём на входах `" - "`, `"-\n"`, `"\t-"`, `" -"` и получил
`source_ref = "-"` вместо `Dune.torrent`; `reimpl` принёс свой тест на разбор.
В дереве остался табличный `TestParseNoNameSentinelDropped`.
- **Почему не поймали раньше:** ловить было нечему — дефект внесён этим же
изменением и пойман тем же прогоном. Отмечено потому, что это **эвал-сет
наоборот**: случай, где старшая метка окупилась. Три прохода из семи
(`specs`, `adversary`, `reimpl`) нашли его независимо, и двое принесли оракул;
проход `code` (конвенции) и гейт его не видели — порядок двух операций внутри
функции не выражается ни правилом линтера, ни конвенцией.
- **Что меняем:** ничего в конвейере. Класс «нормализация и сравнение с
константой идут в неверном порядке» дешевле ловить тестом на границе разбора,
чем правилом; такой тест заведён. Наблюдение о самой библиотеке (что
`BestName()` сентинел **не** синтезирует — исходное основание нити было
неверным) записано в `research/torrent-bencode-limits.md`.
+113
View File
@@ -0,0 +1,113 @@
# Модель угроз
## Периметр
**Контур доверенный: домашняя LAN, публичного интернета здесь нет — не
выдумывай его.** Сервис слушает `:8080` на хосте umbar внутри локальной сети,
наружу не проброшен, доменного имени и обратного прокси у него нет. Веб-UI и
REST API работают **без авторизации** осознанно; поле `[http].trusted_subnets`
зарезервировано, но не применяется.
Целевой периметр — **тот же**: выставлять jellybit в интернет не планируется.
Если это когда-нибудь изменится, первым шагом идёт задача «Авторизация веб-UI»,
и модель угроз пересматривается целиком, а не дополняется.
**Находки строятся против сегодняшнего периметра.** «Любой может открыть
страницу и удалить загрузку» — это принятое решение, а не дефект; чтобы стать
дефектом, ему нужен путь снаружи LAN.
## Недоверенный вход
Что приходит извне и каким каналом. Всё перечисленное контролируется не нами и
может быть враждебным по содержанию, даже когда канал доверенный.
| Что | Канал | Чем опасно |
| --- | --- | --- |
| Имена файлов и каталогов раздачи | qBittorrent API | разделители пути, `..`, управляющие символы, юникод-омоглифы, длина сверх лимита ФС |
| Имя торрента, поля magnet (`dn`, `tr`) | приём | то же плюс подстановка в промпт |
| Байты `.torrent` | приём (файл на форме) | bencode-разбор недоверенных данных, размер, вложенность |
| Текстовый контекст человека | все транспорты | попадает в промпт LLM целиком |
| Сообщение торрент-бота | Telegram (пересылка) | чужой формат, парсер, ссылки; текст автора бота, а не отправителя |
| **Ответ LLM** | HTTP к эндпоинту | целиком под влиянием входа выше; названия, годы, номера сезонов и серий, из которых строится целевой путь |
| Ответы метабаз | HTTP к TMDB/TVDB/TVMaze | канонические названия, из которых тоже строится путь; чистятся наравне с выходом LLM на каждой точке входа в план ([ADR-2026-08-10-sanitize-at-every-entry](adr/ADR-2026-08-10-sanitize-at-every-entry.md)) |
| Ответы qBittorrent | HTTP | пути, состояния, размеры |
| Запросы веб-UI и REST | LAN | идентификаторы, параметры действий |
| Пачка идентификаторов и признак подтверждения на групповом удалении | форма веб-UI | необратимое действие сразу по многим загрузкам; разбор, схлопывание дублей и предел размера стоят на **обеих** границах — подтверждении и исполнении, потому что вторая получает пачку формой заново |
**Выход LLM не отвечает за безопасность.** Инъекция в промпт считается
состоявшейся по умолчанию; защита стоит ниже — на валидации целевого пути.
## Из чего строятся пути и ключи
Отсюда строится выход за пределы песочницы — самое ценное место для враждебного
прохода.
- **Целевой путь** = `paths.movies`/`paths.series` + имя папки тайтла + (для
сериала) `Season NN` + имя файла + расширение. Имя папки и файла собираются в
`internal/naming` из полей распознавания: `title`, `original_title`, `year`,
`season`, `episode`, provider-тег вида `[tmdbid-…]`. **Все эти поля —
недоверенный вход.**
- **Правило:** компоненты санитизируются (убираются разделители пути, `..`,
управляющие символы), финальный `filepath.Clean`-путь обязан быть **строго
под** соответствующей библиотекой, иначе операция отклоняется. Проверка на
результате, а не на входе. Следом — длина: каждый компонент обязан помещаться в
255 байт UTF-8, иначе задача уходит в `review`. Порядок значим: путь, вышедший
за песочницу, отклоняется как выход за библиотеку, а не как длинное имя, иначе
находка безопасности спряталась бы за косметической причиной.
- **Исходный путь** = `save_path` из qBittorrent + относительное имя файла из
`/torrents/files`. Тоже недоверенный, но по нему мы только **читаем и
линкуем**; писать в `paths.downloads` нельзя вообще.
- **Ключ идентичности загрузки** — инфохэш (v1 SHA-1 / v2 SHA-256), нормализуется
в lowercase hex фиксированной длины. Крафт-магнет с чужим или подставным
хешем — известное направление атаки на владение (задача в беклоге).
- **Идентификаторы сущностей** — ULID, `ident.Parse` на каждой входной границе:
строка из запроса не доходит до SQL непроверенной.
- **Владение целевым путём** — один путь, один владелец-`file_link`; смена
владельца возможна только на свободном пути.
## Что разграничивает доступ
- **Telegram** — allowlist `telegram.allowed_user_ids`, **fail-closed**: пустой
список запрещает всем. Это единственное реальное разграничение в системе.
- **Веб-UI и REST** — не разграничивают ничего: любой в LAN может всё.
Осознанно, см. «Периметр».
- **Файловая система** — контейнер под `1000:1000`, смонтирована только
песочница `/srv/media` и собственные каталоги `/config` (ro) и `/data`.
`/srv/applications` целиком в контейнер не попадает.
- **qBittorrent** — логин и пароль WebUI; docker-подсеть намеренно не входит в
его LAN-whitelist.
## Что чувствительнее чего
1. **Медиафайлы в раздаче** — единственное, что невосстановимо. Отсюда
инвариант «источник неприкосновенен» и гард последней копии в Undo
(`nlink <= 1` → отказ целиком).
2. **База `/data/jellybit.db`** — восстановима только перезапуском всей работы:
теряется всё in-flight состояние и история привязок.
3. **Секреты**: пароль qBittorrent, ключи LLM и метабаз, токен Telegram,
API-ключ Jellyfin. Живут только в `config.toml` (`0600`, рендерит деплой) и
**никогда не попадают в логи, в диагностику состояния и в ответы API**
правило в [conventions/logging.md](conventions/logging.md).
4. **Библиотечные хардлинки** — восстановимы повторной раскладкой, поэтому
ниже по шкале, хотя видны пользователю первыми.
## Что вне модели
Перечислено явно: против этого находки не строятся.
- **Злонамеренный участник LAN.** Сеть считается доверенной; «сосед по вайфаю
удалил загрузку через веб-UI» — не дефект в сегодняшнем периметре.
- **Злонамеренный оператор.** Владелец может всё по определению, включая
удаление раздачи вместе с файлами.
- **Компрометация соседних сервисов** — qBittorrent, Jellyfin, LLM-эндпоинта,
хоста umbar. Если qBittorrent врёт про пути, мы проиграли раньше.
- **Отказ в обслуживании изнутри контура.** Огромная раздача, тысяча файлов,
бесконечный ответ LLM — это вопросы устойчивости и ресурсов
([architecture.md](architecture.md) → «Эксплуатация»), а не безопасности.
Тело ответа внешнего сервиса при этом читается с пределом: LLM — 8 MiB
(`internal/llm`), метабазы — 4 MiB (`internal/metadata`).
- **Целостность содержимого медиафайлов.** Что в контейнере mkv — не наша забота.
- **Цепочка поставки** — модули Go, базовый образ distroless, плагины тулинга.
- **Приватность запросов к внешним сервисам.** Названия раздач уезжают в LLM и
метабазы; это принято сознательно, прокси в конфиге есть.
- **Физический доступ к серверу и бекапам.**
-26
View File
@@ -1,26 +0,0 @@
# Спецификации
Живые документы о том, как устроена система — целевое и актуальное
состояние. В отличие от ADR, спецификации **изменяемы**: их правят по
мере развития проекта и держат в соответствии с кодом. В отличие от
черновиков, описывают принятое и реализуемое, а не идеи.
## Соглашения
- Имя файла — `kebab-topic.md`, без дат (дата живёт в git-истории).
- Одна спецификация — одна тема.
- Если решение требует объяснения «почему именно так» с долгим следом —
заведи ADR и сошлись на него из спецификации.
## Записи
- [architecture.md](architecture.md) — общее устройство: компоненты,
транспорты, хранилище, раскладка, деплой.
- [workflow.md](workflow.md) — жизненный цикл загрузки: машина состояний,
переходы, сопоставление состояний qBittorrent.
- [recognition.md](recognition.md) — распознавание контента и модель
уверенности.
- [review-ux.md](review-ux.md) — ревью раскладки человеком: UI/UX-сценарии
на случай, когда система не уверена.
- [jellyfin-layout.md](jellyfin-layout.md) — конвенции именования файлов
Jellyfin, в которые раскладываем.
-279
View File
@@ -1,279 +0,0 @@
# Архитектура
## Назначение
Jellybit принимает торрент с текстовым контекстом, скачивает его через
qBittorrent, определяет содержимое (фильм или сериал с сезонами и
сериями) и раскладывает файлы по конвенциям Jellyfin — хардлинками, не
трогая исходную раздачу.
## Принципы
- **Один статический бинарь.** Доставка — копированием на сервер. См.
[ADR-2026-06-13-go-single-binary](../adr/ADR-2026-06-13-go-single-binary.md).
- **Источник неприкосновенен** (жёсткий инвариант). jellybit делает
только `mkdir`, `link(2)` и `unlink` *своих* целевых ссылок (для undo).
Никогда не `unlink`/`rename` под `paths.downloads`. См.
[ADR-2026-06-13-hardlinks](../adr/ADR-2026-06-13-hardlinks.md).
- **Выход распознавания недоверенный.** Имена файлов, контекст и
сообщение бота управляются извне. Целевой путь всегда санитизируется и
проверяется, что он строго под `paths.movies`/`paths.series` (см.
«Раскладка файлов»). Безопасность держится на валидации, не на промпте.
- **Единое ядро, тонкие транспорты.** Логика приёма — в use-case
`Ingest`; переходы состояний принадлежат `worker`. HTTP API, веб-UI и
Telegram складывают команды, `worker` их сериализует.
- **Опциональные внешние зависимости.** Базы метаданных (TMDB/TVDB)
включаются конфигом; без них сервис работает на одном LLM, но
авто-раскладка без матча в базе не делается (см. recognition.md).
- **Минимум компонентов.** В духе umbar — без лишних сервисов.
## Компоненты
| Пакет | Ответственность |
| ----------- | -------------------------------------------------------- |
| `ingest` | use-case приёма загрузки, общий для всех транспортов |
| `qbt` | клиент qBittorrent WebUI API (сессия, добавление, опрос) |
| `worker` | владелец машины состояний; поллинг, сериализация команд |
| `recognize` | пред-парс имени + вызов LLM + модель уверенности |
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
| `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) |
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
| `httpapi` | REST + веб-UI (server-rendered, POST-формы с redirect) |
| `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги |
| `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) |
| `config` | загрузка TOML-конфига |
## Поток и машина состояний
Жизненный цикл загрузки (ingest → downloading → … → done/reverted),
полный граф состояний с переходами и сопоставление состояний qBittorrent —
в отдельной спецификации [workflow.md](workflow.md). Ключевое: переходами
владеет `worker`, он же сериализует команды транспортов под per-download
блокировкой, а состояние персистентно в SQLite.
## Транспорты
Все ведут в один `Ingest(req)`; действия пользователя (apply / refine /
reject / defer / undo) — команды к `worker`:
- **HTTP API + веб-UI** — форма «добавить», список, экран ревью
(server-rendered). В v1 **без авторизации** (доверенная LAN). Поле
`http.trusted_subnets` зарезервировано, но **пока не применяется**:
деплой только в локальную сеть без доступа из интернета, поэтому
allowlist-middleware и авторизацию отложили — задача
[«Авторизация веб-UI»](../backlog/avtorizaciya-web-ui.md) в беклоге.
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
- **CLI** — `jellybit add <magnet> --context "..."` для отладки.
Источник (magnet / `.torrent` / URL) **отдаём в qBittorrent** — он сам
скачивает; jellybit не делает исходящих запросов на пользовательский URL
(SSRF исключён).
## Хранилище
SQLite. Полная схема (таблицы, поля, связи) — [database.md](database.md),
поддерживается вместе с миграциями. Схема покрывает приём, цикл ревью и
откат:
- `download``id`, тип и значение источника, контекст, `infohash`,
`idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
(infohash может появиться позже приёма — для magnet без метаданных.)
- `recognition` — попытки распознавания: `download_id`, `attempt_no`,
`is_current`, тип, название, год, `provider` (`tmdb|tvdb|tvmaze|none`),
`provider_id`, `confidence`, причины-не-авто, сырой ответ LLM и
структурированный `plan` (каноничный JSON `recognize.Plan` — файл →
роль/сезон/серия для превью и применения).
- `hint` — накопленные подсказки человека (`download_id`, текст, время).
- `override` — запиненные ручные правки полей (перераспознавание не
затирает).
- `metadata_candidate` — кандидаты базы для выбора (`recognition_id`,
provider, id, название, год, выбран ли).
- `file_link``download_id`, `apply_batch_id`, исходный → целевой путь,
вид (видео/субтитры/…), статус, время. Батч нужен для точечного undo.
### Идентификация торрента и повторное добавление
Идентификатор торрента — **infohash** (v1 SHA-1 / v2 SHA-256): берём из
magnet (`xt=urn:btih:`) или считаем из `.torrent`; этим же оперирует сам
qBittorrent. Идемпотентность — **только для активных задач**: повторное
добавление, пока задача в работе, присоединяется к ней. Если прежняя
задача для этого infohash уже терминальна (`done`/`cancelled`/`failed`/
`reverted`), новое добавление заводит **новую** задачу — перекачать тот же
торрент спустя месяцы можно без проблем (покажем, что infohash уже
обрабатывался, и прежний результат). Разные раздачи одного фильма (репаки)
имеют разные infohash → разные задачи.
## Конфигурация
TOML. Полный список параметров с комментариями — в
[`config.example.toml`](../../config.example.toml) (источник истины, не
дублируем его здесь). Реальный `config.toml` рендерится при деплое
Ansible-шаблоном из переменных umbar (секреты — `vars/secrets.yml` под
ansible-vault), на диске **0600**, владелец `1000:1000`, не коммитится.
Структура секций: `[qbittorrent]` (доступ + категория/тег для push/pull),
`[paths]` (хост-пути песочницы), `[storage]` (путь к SQLite), `[llm]`
(провайдер распознавания, см. [recognition.md](recognition.md)),
`[metadata.tmdb|tvdb|tvmaze]` (опц. базы), `[jellyfin]` (опц.
пересканирование), `[worker]` (интервал поллинга и таймауты, см.
[workflow.md](workflow.md)), `[recognition]` (порог уверенности),
`[telegram]`, `[http]`, `[log]`.
## Логирование
Структурированный JSON через `log/slog`, в stdout (docker подбирает).
Каждая загрузка проходит со сквозным идентификатором; решения
распознавания (почему авто/ревью) и операции с файлами логируются явно.
## Раскладка файлов
`layout` создаёт хардлинки в `paths.movies`/`paths.series` по конвенциям
Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
- **Линкуем только файлы.** Целевые каталоги создаём `mkdir -p` (режим
0755, владелец `1000:1000`); каталог не хардлинкуется.
- **Путь сначала санитизируется:** из `title`/сезона/серии убираем
разделители пути, `..`, управляющие символы; финальный
`filepath.Clean`-путь обязан быть строго под библиотекой, иначе отказ
(защита от traversal).
- **Никогда не перезаписываем.** Цель существует и это тот же inode →
готово (идемпотентно); существует и это другой файл → коллизия → review.
- **Батч фиксируется в БД:** статус по каждому файлу; повтор после сбоя
доводит начатое (идемпотентно) либо откатывается.
- **Undo** удаляет только ссылки своего `apply_batch_id` и только если
путь под `paths.movies`/`series` — источник недосягаем.
- **Хардлинк предпочтителен, но есть фолбэк.** По построению источник и
цель — на одной ФС (единая песочница `/srv/media`), и `link(2)` проходит.
Если ФС всё же не поддерживает жёсткие ссылки или они между разными ФС
(`EXDEV`/`ENOTSUP`/`EOPNOTSUPP`/`EPERM`), `layout` **не падает**, а
копирует файл (через временный файл + атомарный `rename`) и пишет в лог
`Warn` (статус ссылки — `copied`): задача доходит до конца ценой
дублирования места. Источник при этом всё равно не трогаем.
### Пути и контейнеры — единая песочница `/srv/media`
Весь медиа-стек лежит под одним каталогом и монтируется **идентично**
(`/srv/media:/srv/media`) во все медиа-приложения:
```
/srv/media/
incomplete/ ← qBit качает сюда
downloads/ ← готовые раздачи (источник хардлинка)
movies/ series/ ← библиотека Jellyfin (цель хардлинка)
```
Так как всё под одним mount'ом, и **хардлинк** (downloads → movies/series),
и **мгновенный move** qBit (incomplete → downloads) работают — нет границ
между точками монтирования (`EXDEV`). Путь из qBittorrent
(`save_path`/`content_path`) уже равен хост-пути, трансляция не нужна
(`path_map` — фолбэк, обычно пуст). Секреты и чужие приложения
(`/srv/applications`) в эту песочницу не попадают.
- **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`.
- **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой
SQLite — отдельным mount'ом `/srv/applications/jellybit/data`, конфиг —
отдельным `/srv/applications/jellybit/config`.
- **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень
`/srv/media`, иначе в индекс попадут downloads/incomplete).
## Пересканирование Jellyfin
Когда наши библиотечные хардлинки меняются, `worker` неблокирующе просит Jellyfin
пересканировать медиатеку, чтобы плеер не держал битые пути и быстрее подхватил
новые файлы. Триггерят входы в `done` (файлы разложены), `reverted` (Undo снял
ссылки) и `deleted` (Delete снял ссылки / сверка констатировала их отсутствие) —
гейт по состоянию-цели в едином чекпоинте перехода, поэтому ловит и
пользовательские Undo/Delete, и reconcile-производный `deleted`. Промежуточный
рассинхрон (`target_missing`/`orphaned`) не сканируем — задача ждёт
relink/лечения. Включается конфигом `[jellyfin]` (по умолчанию выключено); без
него скан не дёргается.
- **Один вызов — `POST /Library/Refresh`** (скан всех библиотек). Скан
инкрементальный, поэтому полный дёшев; точечный скан конкретной папки не
делаем — сложнее и не в духе сервиса («минимум компонентов»).
- **Авторизация** — API-ключ Jellyfin в заголовке `X-Emby-Token`.
- **Неблокирующе и вне `w.mu`** (как пинги Telegram): вызов уходит в сеть в
отдельной горутине с фоновым контекстом. Недоступность Jellyfin не влияет на
состояние задачи — ошибка лишь логируется (`Warn`).
- **Адресация** — по имени сервиса в общей docker-сети (`http://jellyfin:8096`).
## Деплой
Jellybit работает в **docker** — в одной среде с qBittorrent и Jellyfin
(см. [ADR-2026-06-13-docker-deploy](../adr/ADR-2026-06-13-docker-deploy.md)).
Сборка: статический бинарь (`GOOS=linux GOARCH=amd64 CGO_ENABLED=0`,
сервер на Intel N150) собирается здесь; на сервер во временную build-папку
кладутся бинарь + `Dockerfile` (копирует бинарь в `distroless/static`),
образ собирается на месте и запускается. Go-тулчейн на сервере не нужен.
Параметры запуска (в umbar-compose):
- **Общая docker-сеть** (external, напр. `media-net`) — jellybit, qBit и
(позже) Jellyfin в ней; адресуемся по именам (`http://qbit:8989`,
`http://jellyfin:8096`). Веб-UI jellybit публикуем на хост (`8080:8080`)
для LAN. Учесть: qBit валидирует Host-заголовок — выставить
`WebUI\ServerDomains=*` (umbar); LLM на хосте достаётся через
`host.docker.internal` (`extra_hosts: host-gateway`).
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
- **mount `/srv/media`** (единая песочница) — для хардлинков и move
(см. «Пути и контейнеры»); каталоги jellybit — отдельно.
- **mount конфига** `/srv/applications/jellybit/config``/config` (ro):
`config.toml` (0600). Восстановим при деплое (рендерит плейбук umbar) —
бекапить не нужно.
- **mount данных** `/srv/applications/jellybit/data``/data`: SQLite
(`/data/jellybit.db`). Бекапить-и-не-терять — без него редеплой стёр бы
всё in-flight состояние.
- **healthcheck** на `/healthz`.
Разделение ответственности:
- **jellybit** (этот репозиторий) — статический бинарь и `Dockerfile`.
- **umbar** — оркестрация: доставка артефактов, `docker build`, запуск
через docker compose (`playbook-jellybit.yml`) с параметрами выше.
## Предполагаемая структура репозитория
```
cmd/jellybit/ точка входа, сборка зависимостей
internal/
ingest/ qbt/ worker/ recognize/ llm/ metadata/
layout/ store/ httpapi/ tgbot/ config/
migrations/ миграции SQLite
web/templates/ шаблоны веб-UI
docs/ specs / adr / drafts
Dockerfile .dockerignore config.example.toml
```
## Решённые вопросы
- Пути/контейнеры — единая песочница `/srv/media:/srv/media` (подпапки
incomplete/downloads/movies/series) монтируется идентично во все
медиа-приложения; путь из API = хост-путь; хардлинк и move в пределах
одного mount'а. `/srv/applications` в песочницу не попадает.
- Сеть — общая docker-сеть, адресация по именам (`qbit:8989`); host-режим
не используем. qBit: `WebUI\ServerDomains=*`; LLM на хосте — через
`host.docker.internal`.
- qBit: «incomplete» включён (`/srv/media/incomplete`), завершение
проходит через `moving`; jellybit авторизуется логином/паролем
(docker-подсеть не входит в LAN-whitelist qBit).
- Внешние базы — HTTP-прокси на клиента (`proxy` в `[metadata.*]`/`[llm]`).
- Идентификатор торрента — infohash; идемпотентность только для активных
задач (повторная закачка спустя время → новая задача).
- Состояние — на persistent-томе `/srv/applications/jellybit/data`.
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
- Пересканирование Jellyfin при изменении наших ссылок — `POST /Library/Refresh`
(скан всех библиотек, инкрементальный), неблокирующе на входе в `done`/
`reverted`/`deleted`; опц., включается `[jellyfin]`.
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
- Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
- Форма запуска — docker, образ собирается на сервере; контейнер под
`1000:1000`, в общей docker-сети, mount `/srv/media` + data-том.
## Открытые вопросы
- (пока нет)
-159
View File
@@ -1,159 +0,0 @@
# Схема базы данных
Актуальная схема SQLite-хранилища: таблицы, поля и связи. Это **живой**
документ — его поддерживаем в соответствии с миграциями.
> **Поддержка вместе с миграциями.** Источник истины по схеме —
> `internal/store/migrations/*.sql` (goose). При **каждой** новой миграции,
> меняющей структуру (таблица/столбец/индекс/связь), обновляем эту диаграмму
> в том же change. Расхождение схемы с миграциями считаем багом
> документации.
>
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`,
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`),
> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC,
> `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла),
> `0010_retried_at`, `0011_parsed_context` (структура имени из контекста, JSON).
Назначение таблиц и почему так — [architecture.md](architecture.md) →
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
Первичные ключи — ULID (TEXT, lowercase), генерятся приложением
(`internal/ident`) — см. [конвенцию](../conventions/database.md). Метки времени
(`created_at`/`updated_at`) — TEXT в RFC 3339, UTC (суффикс `Z`); пишет
приложение (`store.Now`/`FormatTime`), без `DEFAULT` на колонках.
## ER-диаграмма
```mermaid
erDiagram
download ||--o{ download_infohash : "инфохэши (v1/v2)"
download ||--o| download_torrent : "байты .torrent (1:0..1)"
download ||--o{ recognition : "распознавания"
download ||--o{ hint : "подсказки"
download ||--o{ override : "ручные правки"
download ||--o{ file_link : "хардлинки"
recognition ||--o{ metadata_candidate : "кандидаты базы"
download {
TEXT id PK "ULID (lowercase), генерится приложением"
TEXT source_type "NOT NULL; magnet|torrent|url"
TEXT source_ref "NOT NULL; magnet/url/путь"
TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)"
TEXT context "NOT NULL DEFAULT ''"
TEXT parsed_context "NOT NULL DEFAULT ''; структура имени из контекста (naming, JSON), базовый слой display_name (миграция 0011)"
TEXT state "NOT NULL; см. workflow.md; активность выводится только из state"
TEXT error_code "nullable"
TEXT error_msg "nullable"
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
TEXT retried_at "nullable; время последнего ручного retry (RFC 3339 UTC Z), сброс базиса таймаутов (миграция 0010)"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
download_infohash {
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; PK(infohash, download_id)"
TEXT infohash PK "NOT NULL; lowercase hex (40 — v1, 64 — v2)"
TEXT kind "NOT NULL; v1|v2"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
download_torrent {
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; байты source_type=torrent"
BLOB data "NOT NULL; исходные байты .torrent для добавления файлом (миграция 0009)"
}
recognition {
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
INTEGER attempt_no "NOT NULL DEFAULT 1"
INTEGER is_current "NOT NULL DEFAULT 1; 0/1"
TEXT media_type "nullable; movie|series"
TEXT title "nullable"
TEXT original_title "nullable"
INTEGER year "nullable"
TEXT provider "nullable; tmdb|tvdb|tvmaze|none"
TEXT provider_id "nullable"
REAL confidence "nullable"
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто"
TEXT raw_llm "nullable; сырой ответ LLM"
TEXT plan "nullable; JSON recognize.Plan (миграция 0002)"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
hint {
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT text "NOT NULL"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
override {
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT field "NOT NULL; UNIQUE(download_id, field)"
TEXT value "NOT NULL"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
metadata_candidate {
TEXT id PK "ULID"
TEXT recognition_id FK "NOT NULL; ON DELETE CASCADE"
TEXT provider "NOT NULL"
TEXT provider_id "NOT NULL"
TEXT title "nullable"
INTEGER year "nullable"
TEXT url "nullable; ссылка на страницу на сайте провайдера"
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
file_link {
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT apply_batch_id "NOT NULL; батч для точечного undo"
TEXT src_path "NOT NULL; исходный файл раздачи"
TEXT dst_path "NOT NULL; целевой хардлинк"
TEXT kind "NOT NULL; video|subtitle|..."
TEXT status "NOT NULL; linked|..."
INTEGER size "NOT NULL DEFAULT 0; размер файла (байт), фолбэк размера раздачи"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
```
## Связи и кардинальность
- `download` 1 — N `download_infohash` / `recognition` / `hint` / `override`
/ `file_link`; `recognition` 1 — N `metadata_candidate`. Все дочерние — с
`ON DELETE CASCADE`: удаление загрузки уносит её хеши, распознавания,
подсказки, правки и ссылки.
- `download_infohash` — множество хешей одной загрузки (v1/v2 гибридного
торрента); один и тот же infohash может принадлежать нескольким загрузкам
во времени (повторный приём после терминального состояния). Инвариант «не
более одной активной загрузки на infohash» держат guarded-методы store
(`CreateDownloadIfNoActive`/`ActivateIfNoOtherActive`) в одной
write-транзакции — на уровне схемы он не выражается (условие на `state`).
- `download` 1 — 0..1 `download_torrent` — байты исходного `.torrent` (только
у `source_type=torrent`); нужны воркеру для добавления раздачи файлом и для
повторного добавления при retry, поэтому живут весь срок строки загрузки.
`ON DELETE CASCADE` — страховка на будущий delete-путь (сейчас загрузки не
удаляются).
- `download``file_link` — один источник (раздача) ко многим разложенным
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
каждый файл раздачи попадает в `file_link` (только распознанные медиа и
субтитры); ссылки могут накапливаться несколькими `apply_batch_id`.
## Индексы и ограничения
- `download`: индекс по `state`.
- `download_infohash`: PK `(infohash, download_id)` (он же индекс поиска по
хешу); индекс по `download_id`.
- `recognition`: индекс по `download_id`.
- `override`: `UNIQUE(download_id, field)`.
- `metadata_candidate`: индекс по `recognition_id`.
- `file_link`: индексы по `download_id` и по `apply_batch_id`.
> Enum-поля (`source_type`, `state`, `provider`, `kind`, `status`, флаги
> `0/1`) на уровне SQLite — обычный `TEXT`/`INTEGER` без `CHECK`; допустимые
> значения держит код (`internal/store`).
-107
View File
@@ -1,107 +0,0 @@
# Конвенции раскладки Jellyfin
> **Источник истины переехал в OpenSpec** — `openspec/specs/file-layout/` (имена,
> хардлинки, коллизия, copy-fallback). Владение путём (`superseded`) и безопасный
> undo (`nlink<=1`) — в `openspec/specs/state-reconciliation/`. Этот файл —
> справочный нарратив; при расхождении верна спека OpenSpec.
Целевые имена и структура, в которые jellybit раскладывает файлы
хардлинками. Источники:
[Movies](https://jellyfin.org/docs/general/server/media/movies),
[Shows](https://jellyfin.org/docs/general/server/media/shows).
## Фильмы
```
movies/
Дюна Часть вторая (2024) [tmdbid-693134]/
Дюна Часть вторая (2024).mkv
Дюна Часть вторая (2024).ru.srt
```
- Папка и файл — `Название (Год)`.
- provider-id в имени папки (`[tmdbid-...]`) добавляется при работе с
базой — снимает неоднозначность для русских названий, которые Jellyfin
иначе может опознать неверно.
- Внешние субтитры — `Имя.<lang>[.flag].srt` (флаги `forced`/`sdh`/
`default`/`hi`), напр. `…ru.forced.srt`; база имени совпадает с именем
видеофайла. Пары VobSub — `.idx` + `.sub`.
## Сериалы
```
series/
Название (2024) [tvdbid-123456]/
Season 01/
Название (2024) S01E01.mkv
Название (2024) S01E02.mkv
```
- provider-id — на папке сериала.
- Сезоны — `Season 01`, файлы — `... SxxEyy`.
- **Сходимость папки:** при подтверждённом матче база папки (имя+год) наследуется
от живой папки-якоря того же `(provider, provider_id)` (существующей на диске), а
не печатается заново из выхода LLM — так второй сезон ложится в ту же папку, что
и первый. Несколько разных живых папок одного матча → review. Источник истины —
`openspec/specs/file-layout/` («Сходимость базы папки…»).
## Сопоставление источник → цель
Источник берём по пути из qBittorrent (`save_path` + относительное имя
файла из `/torrents/files`, которое уже содержит корневую папку
многофайловой раздачи; это уже хост-путь, `path_map` — фолбэк). Для каждого
распознанного **файла** (не каталога) создаётся **хардлинк** в
`paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755,
`1000:1000`). Исходный файл остаётся на месте (раздача продолжается),
inode общий — диск не дублируется.
Целевое имя строится из распознанных полей и **санитизируется** (без
разделителей пути, `..`, управляющих символов); финальный путь обязан
быть строго под библиотекой. Существующую цель **не перезаписываем** (тот
же inode → готово; другой файл → коллизия → review). Инварианты и undo —
в [architecture.md](architecture.md) → «Раскладка файлов».
## Владение целевым путём
Целевой путь принадлежит **одной** загрузке. Когда новая раскладка
успешно ложится на путь, который раньше занимала другая загрузка (путь к
этому моменту **свободен** — иначе была бы коллизия → review, чужой файл
не перезаписываем), владение переходит к новой загрузке: прежние записи
`file_link` на этот путь помечаются статусом `superseded` и перестают
считаться целью прежней загрузки. Это нужно сверке с реальностью: иначе
повторная закачка того же фильма (например, в другом качестве) по тому же
пути ложно «воскрешала» бы удалённую задачу — см.
[workflow.md](workflow.md) → «Сверка с реальностью». `superseded`-ссылки
не считаются целью при сверке и не снимаются в `Undo`.
Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е
(внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда
работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без
поддержки жёстких ссылок), `layout` не падает, а копирует файл с
предупреждением в лог — см. architecture.md → «Раскладка файлов».
## Безопасный undo (не снимать последнюю копию)
`Undo` снимает **лишний** хардлинк, а не единственный файл. Перед удалением
батча `layout` проверяет каждую цель: если исходный файл уже не существует
**или** у цели не осталось других жёстких ссылок (`nlink <= 1`), это —
последняя копия данных, и весь `Undo` отклоняется целиком (ошибка
`ErrLastCopy`), не сняв ни одной ссылки (частичный откат тоже стёр бы часть
данных). Так нарушенный инвариант «источник неприкосновенен» (источник
удалён вручную) не приводит к потере данных. Отсутствующую цель `Undo`
пропускает как уже снятую (идемпотентность). Связь с состояниями
рассинхрона — [workflow.md](workflow.md) → «Сверка с реальностью».
## Крайние случаи
- **Многофайловый фильм** (части) — стэкинг по точному токену Jellyfin
(`… - part1`/`cd1`); точный формат уточнить при реализации.
- **Редакции** — `Имя (Год) [edition-Director's Cut]` либо отдельные
версии в папке фильма.
- **Двойная серия** в одном файле — `… SxxEyy-Eyy`.
- **Спецвыпуски** — `Season 00`.
- **Сезон-пак** — серии в один `Season xx`; смешанный пак — по per-file
сезонам.
- **Несколько аудиодорожек** — обычно внутри mkv, не наша забота.
- **Аниме с абсолютной нумерацией** — пересчёт в S·E, отдельная проработка
([задача в беклоге](../backlog/anime-absolyutnaya-numeraciya.md)).
-144
View File
@@ -1,144 +0,0 @@
# Распознавание контента
> **Источник истины переехал в OpenSpec.** Актуальные требования —
> `openspec/specs/recognition/` (разбор сигналов LLM) и
> `openspec/specs/metadata-match/` (сверка с внешними базами). Этот файл остаётся
> справочным нарративом; при расхождении верна спека OpenSpec.
## Задача
По доступным сигналам определить: фильм или сериал; каноническое название
и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых
базах — провайдер и его id. На выходе — план раскладки, оценка уверенности
и решение «авто или review» (как оно встраивается в машину состояний —
[workflow.md](workflow.md), состояния `recognizing`/`linking`/`review`).
## Сигналы
- Имя торрента и структура каталогов.
- Список файлов с размерами и расширениями. Абсолютный путь источника
восстанавливаем как `save_path` из qBit (= хост-путь; `path_map` обычно
тождественен) + относительное имя файла из `/torrents/files`. Имя уже
включает корневую папку для многофайловых торрентов, поэтому префикс —
именно `save_path`, а не `content_path` (последний удвоил бы корневую
папку и сломал бы однофайловые раздачи).
- Текстовый контекст человека (+ накопленные подсказки из review).
- Распарсенное сообщение торрент-бота (если через Telegram): название с
годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md).
**Все сигналы недоверенные** — имя торрента, сообщение бота и контекст
управляются извне и могут содержать инъекции. Выход LLM не отвечает за
безопасность: целевой путь всё равно санитизируется и проверяется на
выход за пределы библиотеки (см. architecture.md → «Раскладка файлов»).
## Конвейер
1. **Пред-парс** имени релиза (`go-ptn`): черновые название/год/сезон/
серия и качество. Грубо, но бесплатно.
2. **LLM** (через провайдер-абстракцию, см. ниже): получает сигналы и
пред-парс, возвращает структурированный план в нашей схеме. Хорошо
берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под
контекст модели.
3. **Сверка с базой** (если включена TMDB/TVDB/TVMaze): ищем по
названию+году, берём официальный id и каноническое имя, собираем
кандидатов. TVMaze — без ключа, только сериалы; внешний id
(TVDB/IMDb) из `externals` идёт в имя папки.
- **Поиск по нескольким названиям** в порядке убывания силы ключа:
`original_title` → локализованное `title``provider_hint`. Базы
индексированы прежде всего по оригинальным названиям, поэтому
оригинал — первым; останавливаемся, как только очередной ключ дал
единичный сильный матч. Пустые и нормализованно-дублирующие ключи
пропускаем (русский фильм, где оригинал = локализованное, дёргает
базу один раз). Кандидатов для review копим из всех заходов.
- **Локаль TMDB:** запрос передаёт `language` (по умолчанию `ru-RU`,
настраивается `[metadata.tmdb].language`). Влияет только на
локализованный `Title`/`Name`; `original_title`/`original_name`
остаётся на языке оригинала, поэтому оригинальная сторона сравнения
не страдает, а русская — сходится.
- **Нормализация названий** при сравнении сводит `ё``е` («Тёмный» и
«Темный» — одно название).
4. **Оценка уверенности** и решение: авто или review.
## Структура ответа LLM (предварительная)
```
type movie | series
title каноническое название
original_title оригинальное (обычно англ.) название — заполняется всегда:
нет отдельного / российский контент → дублирует title;
при неуверенности дублируем, а не выдумываем
year год
provider_hint строка для поиска в базе (НЕ итоговый id)
files[] { src, role: main|episode|subtitle|extra|sample|ignore,
season?, episode? } # season/episode — на файл
confidence 0..1 — самооценка модели (вспомогательный сигнал)
notes пояснения, неоднозначности
```
Сезон/серия — **на файле**: так выражаются мультисезонные паки,
спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет.
`provider_hint` — только подсказка для поиска; итоговые `provider`
(`tmdb|tvdb|tvmaze|none`) и `provider_id` появляются после сверки с базой
и хранятся отдельно.
## Провайдер LLM
Доступ к LLM — за интерфейсом; реализация выбирается полем `[llm].type`
(дискриминатор). Это позволяет подключать локальные модели и сторонние
(в т.ч. китайские) эндпоинты — ради экономии и независимости от вендора.
- Первый и пока единственный тип — **`openai-compat`**: OpenAI-совместимый
Chat Completions API (`base_url` + `api_key` + `model`). Подходят
локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые
провайдеры (DeepSeek, Qwen и др.).
- **Структурированный вывод надёжно:** просим JSON-режим
(`response_format: {"type":"json_object"}`) — это поддерживают и мелкие
локальные модели, в отличие от строгих JSON Schema. На приёме срезаем
```-ограждения и извлекаем JSON, **валидируем в Go** против нашей схемы;
при ошибке разбора ретраим, передавая модели саму ошибку и схему в
промпте, до `llm.max_retries`. Если так и не распарсилось — уходим в
**review** (не в `failed`) с причиной «ответ LLM не разобран».
- Новые типы (напр. нативный `anthropic`) добавляются, не трогая
`recognize`.
## Модель уверенности
Почему авто только при матче в базе, а не по самооценке LLM —
[ADR-2026-06-13-auto-link-requires-db-match](../adr/ADR-2026-06-13-auto-link-requires-db-match.md).
Авто-раскладка — только если выполнено **всё**:
1. **Подтверждённый матч в базе** — единственный сильный результат
TMDB/TVDB/TVMaze по названию+году, давший `provider_id`. **Нет матча (или
база выключена) → всегда review.** Это и закрывает основной кейс
(рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал».
2. **Структурная валидация** без предупреждений:
- фильм: ровно один основной видеофайл (семплы/экстра/ignore отброшены);
- сериал: число серий бьётся с базой, нумерация S·E консистентна, без
пропусков, дублей и неоднозначных спецвыпусков.
3. **Согласованность сигналов** — пред-парс (`go-ptn`) и LLM не
противоречат по типу/названию/году.
Самооценку LLM (`confidence`) учитываем как вспомогательный сигнал, но
**не как единственный гейт**: она плохо откалибрована и поддаётся
инъекции. Решают матч в базе и валидация.
Иначе — **review** ([review-ux.md](review-ux.md)) с явной причиной.
## Что делаем с краёв
- Семплы/«экстра»/мусор → роль `ignore` (эвристики размер/имя + LLM).
- Внешние субтитры (`.srt`, `.ass`, пары VobSub `.idx`+`.sub`) привязываем
к видео и именуем по Jellyfin (`*.ru.srt`).
- Сезон-паки разбираем по сериям; смешанные паки, спецвыпуски (`Season
00`), двойные серии (`SxxEyy-Eyy`) — через per-file season/episode;
любая неоднозначность → review.
- Аниме с абсолютной нумерацией — отдельный крайний случай,
[задача в беклоге](../backlog/anime-absolyutnaya-numeraciya.md).
## На будущее
`go-ptn` слабее питоновского `guessit`. Если точности пред-парса не
хватит — завернуть `guessit` лёгким сервисом-спутником (один файл рядом с
бинарём). Задача [«guessit как сервис-спутник»](../backlog/guessit-sputnik.md)
в беклоге.
-167
View File
@@ -1,167 +0,0 @@
# Ревью раскладки человеком
> **Источник истины переехал в OpenSpec** — `openspec/specs/review/`. Этот файл
> остаётся справочным нарративом (UI-макеты, разбор сценариев); при расхождении
> верна спека OpenSpec.
Что происходит, когда система не уверена в распознавании и не
раскладывает файлы автоматически. Когда именно наступает ревью — см.
[recognition.md](recognition.md); место состояния `review` в общем потоке —
[workflow.md](workflow.md); конвенции целевых имён —
[jellyfin-layout.md](jellyfin-layout.md).
Главный принцип: ревью — это **петля «догадка → подсказка человека →
перераспознавание»**, а не статичное «ок/нет». Человек остаётся
супервизором, а не оператором ручного ввода.
## Когда наступает
Загрузка уходит в `review`, если сработал любой триггер модели
уверенности: низкая самооценка LLM; нет матча в базе (или несколько
кандидатов); структурная валидация ругается (у фильма >1 основного
файла; число серий не бьётся с базой; дыры/дубли в нумерации S·E).
В интерфейсе всегда видна **конкретная причина**, а не просто «не уверен».
## Поверхность решения (едина для всех транспортов)
1. **Источник:** имя торрента, переданный контекст, дерево файлов с
размерами, (если из бота) распарсенное сообщение.
2. **Догадка системы:** тип, название, год, сезон, матч базы и
**превью целевой раскладки** — буквальные пути, которые создадутся.
3. **Причина сомнения.**
## Действия
- **Применить** — сделать хардлинки по плану.
- **Уточнить и перераспознать** — добавить подсказку текстом → LLM
перезапускается с исходными сигналами и накопленными подсказками →
новый план. Главный путь, когда «LLM не справился».
- **Поправить вручную** — объём зависит от версии (см. ниже).
- **Выбрать кандидата базы** / ввести id / «без базы».
- **Отклонить** / **Позже**.
**Подсказка vs override.** Подсказка мягкая — LLM её интерпретирует.
Ручная правка поля — жёсткий **override**: система берёт значение как
есть и «пиннит» его, перераспознавание не затирает уже поправленное.
## Веб-UI — точные правки
```
Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟡 review
Причины: нет в TMDB · уверенность 0.46
Контекст: «второй сезон, рус+англ дорожки» [+ добавить → 🔁 перераспознать]
Тип: ( ) фильм (•) сериал Название: Фарго Год: 2015 Сезон: 02
Источник совпадения (единый список — выбираем источник, а не режим):
(•) распознано нейронкой (без базы) [активен]
( ) tvdb Fargo · 2014 id 269613 [запись↗] [предпросмотр▸] [выбрать]
( ) tmdb Fargo id 60622 [запись↗] [предпросмотр▸] [выбрать]
+ добавить вручную: [tmdb▾] [id или URL записи] [Добавить]
предпросмотр▸ раскрывает поля (тип/название/год, место под режиссёра) и
целевые пути ЭТОГО источника — до выбора, ничего не меняя
Файлы → серии:
# | файл | размер | роль | S | E
1 | Fargo.S02E01.rus.mkv | 3.1 GB | эпизод | 02 | 01
… [нумеровать подряд] [сброс]
9 | sample.mkv | 40 MB | игнор | | –
Превью:
series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv ← #1
[ Применить ] [ Отклонить ] [ Позже ]
```
Ядро экрана для сериала — таблица «файл → серия» с живой валидацией
дыр/дублей и кнопкой «нумеровать подряд» (частый случай: файлы по
порядку, но подписаны криво). Для фильма проще: выбрать основной файл,
остальное — extra/sample/субтитры/игнор.
## Telegram — быстро, где пользователь и так есть
```
🟡 Нужно подтверждение
Источник: Fargo.S02.2015.WEB-DL.1080p
Похоже на: 📺 сериал «Фарго», сезон 2 (2015)
База: TMDB не найдено · уверенность низкая
План: 10 видео → series/Фарго (2015)/Season 02/…E01E10
[✅ Применить] [📺↔🎬 Тип]
[🔢 Выбрать в базе] [🔁 Уточнить]
[🌐 Открыть в вебе] [❌ Отклонить]
```
- **🔁 Уточнить** → бот просит подсказку ответом → перераспознаёт →
редактирует то же сообщение новым планом. Петля коррекции прямо в чате.
- Точечное переназначение файлов и выбор кандидата базы в чат не
помещаются → **🌐 В вебе** (deep-link на ту же страницу, строится из
`telegram.web_base_url`).
> Реально в боте сейчас: ✅ Применить, 📺↔🎬 Тип, 🔁 Уточнить, 🕗 Позже,
> 🌐 В вебе, ❌ Отклонить. Кнопки «🔢 Выбрать в базе» в чате пока нет —
> выбор кандидата и ручной ввод id делаются в вебе.
## Разделение труда
Telegram = одобрить / подсказать / выбрать кандидата / эскалировать в
веб. Веб = точные правки. Состояние ревью одно (в SQLite); команды из
любого транспорта сериализует `worker` под per-download блокировкой —
гонки двух транспортов нет, применяется последняя валидная команда.
**Доступ.** Telegram — по `telegram.allowed_user_ids` (пусто = запрет
всем). Веб-UI в v1 без авторизации (доверенная LAN), поэтому deep-link из
бота ведёт на открытую страницу — приемлемо по решению; защиту навесим
позже.
## Крайние сценарии
- **База неоднозначна** → выбор кандидата (часто чинит всё разом: пиннит
provider-id и каноническое имя).
- **База пустая (рус/аниме)** → «без базы» или ручной id/url. Аниме с
абсолютной нумерацией → веб-хелпер «absolute → S·E»
([задача «Аниме с абсолютной нумерацией»](../backlog/anime-absolyutnaya-numeraciya.md)).
- **Не тот тип (movie↔series)** → «Уточнить» с явным указанием типа
перераспознаёт план (отдельного переключателя типа нет — тип read-only).
- **Мусор (sample/extra/дубли дорожек)** → роль «игнор».
- **Полный провал** (LLM ничего не вытащил) → веб-«ручной режим»: выбрать
тип, ввести название/год, разложить файлы руками; в Telegram — сразу
эскалация в веб.
## Вход в ревью и откат
- Переход в `review` **пингует** (сообщение в Telegram / бейдж в вебе) —
пользователя зовут, а не он опрашивает. Таймера нет, источник
продолжает сидировать.
- После «Применить» показываем, что создано. **Undo** — убрать созданные
хардлинки одной кнопкой (источник цел); страховка от ошибочного
подтверждения.
- **«Позже»** паркует загрузку в `deferred` (вернётся в review по
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
после применения → `reverted` (удаляет только ссылки своего батча, под
`media`). Полная карта состояний — в [workflow.md](workflow.md).
- После отката или отклонения доступна **«Привязать заново»**: перезапускает
распознавание для той же раздачи (`reverted`/`cancelled → recognizing`) и
снова приводит в review — раскладка всегда требует ручного подтверждения,
авто не делаем. Нужна, когда распознали неверно: откатил/отклонил,
перепривязал, поправил и применил.
- В самом ревью, помимо **«Уточнить»** (подсказка + перераспознавание), есть
**«Распознать заново»** — повторный прогон распознавания без новой подсказки
(контекст и прежние подсказки уже учтены). Полезно, когда модель один раз
споткнулась на разовой ошибке.
## Объём по версиям
- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
заново», **единый список источников совпадения**
(нейронка наравне с кандидатами баз; выбор/переключение/снятие в пользу
нейронки), **ручное добавление источника по id или URL** (TMDB/IMDb — по
URL, TVDB — по числовому id), **предпросмотр полей и целевых путей каждого
источника до применения** (место под режиссёра зарезервировано), пометка
файла «игнор», «Применить»/«Отклонить»/«Позже», Undo и «Привязать заново».
В Telegram — подтверждение с reply-подсказкой
(«Уточнить»), «Позже»/«Отклонить» и эскалация в веб;
пинги о входе в review и готовности.
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
(правка S·E, «нумеровать подряд»), ручной режим при полном провале LLM,
выбор кандидата базы и ввод id прямо в Telegram.
-237
View File
@@ -1,237 +0,0 @@
# Жизненный цикл загрузки и машина состояний
> **Источник истины переехал в OpenSpec.** Прямой путь FSM (downloading →
> completed → stuck/failed, поллинг, усыновление) — `openspec/specs/
> download-tracking/`; сверка с реальностью — `openspec/specs/
> state-reconciliation/`; уведомления — `openspec/specs/notifications/`. Этот
> файл — справочный нарратив по графу состояний; при расхождении верна спека
> OpenSpec.
Как загрузка проходит путь от приёма источника до разложенных файлов:
состояния, переходы и то, что их вызывает. Кто владеет переходами и общее
устройство — в [architecture.md](architecture.md); детали распознавания —
в [recognition.md](recognition.md); действия человека в ревью — в
[review-ux.md](review-ux.md).
## Граф состояний
```mermaid
stateDiagram-v2
[*] --> downloading: ingest (источник отдан в qBittorrent)
downloading --> completed: файлы на месте
downloading --> stuck: stalledDL дольше stuck_after
downloading --> failed: metaDL дольше magnet_timeout (страховка) / error
downloading --> failed: источник пропал из qBittorrent (source_gone, после дебаунса)
completed --> recognizing
recognizing --> linking: авто (матч в базе + валидация)
recognizing --> review: нужно подтверждение / ответ LLM не разобран
review --> linking: Применить
review --> recognizing: Уточнить / Распознать заново
review --> deferred: Позже
review --> cancelled: Отклонить
deferred --> review: любое действие (та же поверхность)
linking --> done
linking --> review: коллизия цели
linking --> failed: ошибка ФС
done --> reverted: Undo
reverted --> recognizing: Привязать заново
cancelled --> recognizing: Привязать заново
stuck --> downloading: Retry / сверка (раздача ожила)
failed --> downloading: Retry / сверка (метаданные пришли)
failed --> completed: сверка (торрент уже готов)
stuck --> completed: сверка (торрент уже готов)
done --> target_missing: сверка — цель удалена
done --> orphaned: сверка — источник пропал
done --> deleted: Удалить (delete)
target_missing --> recognizing: Привязать заново
target_missing --> orphaned: источник тоже пропал
target_missing --> deleted: Удалить / источник тоже пропал (сверка)
orphaned --> deleted: Удалить / цель тоже удалена (сверка)
target_missing --> done: healing (цель вернулась)
orphaned --> done: healing (источник вернулся)
done --> [*]
cancelled --> [*]
reverted --> [*]
deleted --> [*]
note right of cancelled
В cancelled ведут: «Отклонить»
(из нетерминальных) и «Закрыть»
(стоп-кран — из любого состояния,
кроме deleted; только статус)
end note
```
Условно-терминальные состояния — `done`, `cancelled`, `failed`,
`reverted`: задача в них останавливается, но из `failed`/`stuck` есть
**Retry**, а из `reverted`/`cancelled`**Привязать заново**. `stuck`
восстановимо ретраем.
## Состояния и переходы
- **ingest → downloading** — приняли источник + контекст, отдали в
qBittorrent (категория `qbittorrent.category`), записали в БД с ключом
идемпотентности. См. [architecture.md](architecture.md) → «Транспорты».
- **downloading / completed** — `worker` поллит qBittorrent
(`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте
(не `moving`/`checking*`), см. «Завершение в qBittorrent» ниже.
- **recognizing** — `recognize` строит план и оценку уверенности
([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM →
review (не failed).
- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл
`review ⇄ recognizing` — перераспознавание по подсказке. «Уточнить» —
подсказка + перераспознавание; «Распознать заново» — повторный прогон
без новой подсказки, по уже накопленному контексту и подсказкам.
- **deferred** — «Позже» паркует задачу; принимает те же команды, что и
`review`, и возвращается в поверхность ревью по любому действию.
- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем. Коллизия
цели возвращает в review, ошибка ФС → failed. См.
[architecture.md](architecture.md) → «Раскладка файлов».
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
(опц., см. [architecture.md](architecture.md) → «Пересканирование
Jellyfin»); доступен **Undo**`reverted` (убрать созданные ссылки) и
**Удалить**`deleted` (полное удаление, см. ниже). Скан дёргается и при
входе в `reverted`/`deleted` — наши ссылки там сняты, Jellyfin не должен
держать битые пути.
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
(ретраибельна); «Отклонить».
- **reverted / cancelled → recognizing** — «Привязать заново»: после
отката или отклонения можно перезапустить распознавание для той же
раздачи. Перепривязка всегда идёт через review с ручным подтверждением
(авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в
qBittorrent.
## Сверка с реальностью (рассинхрон)
Состояние в БД может разойтись с диском при **ручном** удалении: раздачу
стирают из qBittorrent (источник) или файлы убирают из Jellyfin (целевые
хардлинки). `worker` периодически сверяет уже разложенные задачи с фактом по
двумерной матрице «источник × цель» (источник = раздача в qBittorrent,
цель = разложенные хардлинки на ФС) и выводит состояние:
- **target_missing** — источник на месте, цель удалена. Доступна команда
«Привязать заново» (`→ recognizing`); авто-действий нет.
- **orphaned** — источник пропал, цель (последняя копия данных) на месте.
Команд вперёд нет; `Undo` запрещён (снял бы единственную копию).
- **deleted** — нет ни источника, ни цели; **терминально**: сверка его
больше не переоценивает (см. ниже).
**Undo vs Удалить (delete).** Это разные пользовательские операции. **Undo**
(из `done`) — «перераспознать»: снимает только наши библиотечные ссылки, раздачу
в qBittorrent бережёт, гард последней копии включён (не сотрёт единственный
файл) → `reverted`. **Удалить** (из `done`, `orphaned`, `target_missing`) —
«убрать окончательно, освободить место»: снимает наши ссылки **и** сносит раздачу
с файлами из qBittorrent, гард последней копии осознанно выключен (обход
инварианта «источник неприкосновенен» — только по подтверждению) →
терминальный `deleted`. Идемпотентно к отсутствующей стороне, так что подчищает
остатки из любого из трёх состояний. Инициатор в `deleted` различается по
`error_code`: пользовательское удаление — `user_delete`, вывод сверкой —
`reconcile`. Полные требования — `openspec/specs/state-reconciliation/`.
**Закрыть (dismiss).** Универсальный стоп-кран из любого состояния, кроме
`deleted`: переводит запись в терминальный `cancelled` (`error_code =
"user_dismiss"`), **только меняя статус** — ни файлы (библиотечные хардлинки
`done`/`orphaned` остаются на месте), ни раздачу в qBittorrent не трогает, в
отличие от «Удалить». Служит закрытием зависшей/спорной/лишней записи (напр.
дубля-близнеца в `target_missing`); из `cancelled` дальше доступна перепривязка.
Для нетерминальных ту же роль штатно играет «Отменить» — в UI стоп-кран
показывается там, где иного выхода нет (терминальные, кроме `deleted`).
Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
возврате источника/цели задача переходит обратно (вплоть до `done`) — но
**не из `deleted`**: к терминальной задаче источник не вернётся
(идемпотентность снимается только для активных), а её бывший целевой путь, если
его заняла другая загрузка, отбирается переходом владения (см.
[jellyfin-layout.md](jellyfin-layout.md) → «Владение целевым путём»).
Без этого правила переиспользование пути ложно «воскрешало» бы удалённую
задачу в `orphaned`. Пропажа
**источника** дебаунсится (`[worker].source_missing_threshold` подряд идущих
тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды,
которым нужен источник (relink/распознать/применить/undo), проверяют его
**синхронно перед действием** и не полагаются на фоновую сверку. Полные
требования — `openspec/specs/state-reconciliation/`.
Все переходы и команды идут через `worker` под per-download блокировкой —
два транспорта не гонятся за одно состояние. Состояние персистентно в
SQLite; `worker` периодически сверяет qBittorrent с БД и **усыновляет**
раздачи с нашей категорией (`qbittorrent.category`) **или** тегом
(`qbittorrent.tag`), которых ещё нет в БД, заводя для них задачу в
состоянии `downloading`. Категория ставится на добавляемые нами раздачи
(push, задаёт savepath); тег позволяет подхватить уже существующую
раздачу, не трогая её категорию и файлы (pull).
## Завершение в qBittorrent
`worker` опрашивает qBittorrent и сопоставляет его состояния с нашими:
- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`stoppedUP`/
`queuedUP`/`forcedUP` (имена `paused*`/`stopped*` различаются между qBit
v4 и v5 — поддержаны оба).
- **переходное, ждём:** `moving`/`checkingUP`/`checkingResumeData`/
`allocating` — остаёмся в `downloading`, пока qBit не закончит перенос/
проверку (готовность не объявляем, даже если флаги «UP»).
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/
`queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`.
- **застряло по таймауту (страховка):** `metaDL`/`forcedMetaDL` дольше
`magnet_timeout``failed`; `stalledDL` **простаивающий** дольше
`stuck_after``stuck`. `magnet_timeout` — **редкий страховочный
предохранитель** (дефолт `24h`), а не рабочий механизм: долгий `metaDL`
(медленные трекеры/мало пиров) — это норма, его не убиваем агрессивно. Меры у
двух таймаутов **разные**: `magnet_timeout` мерит **возраст** торрента от
добавления в qBittorrent (`added_on`, фолбэк `created_at`); `stuck_after`
мерит **длительность простоя** — от `last_activity` (последнее движение
данных), а не возраст, иначе долго качавшийся торрент, на миг зашедший в
`stalledDL`, ложно уходит в `stuck` со «stalled for 5h». Оба базиса
приподнимаются до `retried_at` — ручной retry сбрасывает отсчёт, чтобы возврат
в `downloading` не ронял задачу снова на ближайшем тике.
- **ошибка:** `error`/`missingFiles``failed` (`error_code` `qbit_error`) —
это настоящий провал, в отличие от таймаута.
- **источник пропал:** раздача активной загрузки устойчиво (после дебаунса
`source_missing_threshold`, тот же счётчик, что и сверка рассинхрона) исчезла
из qBittorrent (удалил пользователь/другой клиент) → `failed` (`error_code`
`source_gone`). Иначе `downloading` без раздачи оставался бы вечным зомби,
которого никто не двигает (MAJOR-3). В отличие от таймаутов, сверка
`source_gone` **не воскрешает** (удаление намеренно) — но задача штатно
retriable: `Retry` заново отдаёт сохранённый источник.
### Уведомление и восстановление
- Любой переход в `failed`/`stuck` **уведомляет** автора загрузки
(`notifier`), чтобы падение не оставалось незамеченным — включая приёмное
падение `qbit_add` (не удалось добавить в qBittorrent), которое идёт мимо
поллинг-цикла. Повторные падения одной задачи в пределах окна дебаунса
уведомляют лишь раз — чтобы мерцающий `stalled`-торрент
(`stuck``downloading`) не спамил.
- `failed`/`stuck` из-за нашей нетерпеливости (`error_code` `magnet_timeout`/
`stalled`) **не тупик**: фоновая сверка возвращает задачу в поток, как
только источник в qBittorrent ожил и продвинулся за условие падения
(получил метаданные → `downloading`; уже готов → `completed`). Пока торрент
всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания).
Настоящие провалы (`qbit_error`) и намеренная пропажа источника
(`source_gone`) сверкой не воскрешаются — только ручной retry.
- Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только
REST): возвращает в `downloading`, перецепляясь к живому **здоровому** торренту
без повторного `Add` (к сломанному — `error`/`missingFiles` — не
перецепляемся, повторно отдаём источник) и сбрасывая базис таймаутов
(`retried_at`), чтобы задача не упала снова на ближайшем тике.
Пути файлов берём из API (`save_path` + относительные имена из
`/torrents/files`, уже включающие корневую папку торрента), не из
константы (обычно это уже хост-путь). «Incomplete»-каталог в
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
`moving` — дожидаемся окончания переноса и только потом берём финальный
путь). Подробнее о путях и песочнице — [architecture.md](architecture.md)
→ «Пути и контейнеры».
+2
View File
@@ -2,6 +2,8 @@ module git.vakhrushev.me/av/jellybit
go 1.26 go 1.26
toolchain go1.26.5
require ( require (
github.com/anacrolix/torrent v1.61.0 github.com/anacrolix/torrent v1.61.0
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.1.0
+180
View File
@@ -0,0 +1,180 @@
// Package archrules — тесты-сканеры исходников для правил, которые не
// выражаются линтером: структура проекта и SQL миграций.
//
// Каждое правило здесь — бывшая строка прозаической конвенции: у него есть
// детерминированный оракул, поэтому ему место в конвейере сборки, а не в
// промпте ревью (процедура промоута — references/promote.md скилла
// av-dev-pipeline:review-pipeline).
package archrules
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
const modulePath = "git.vakhrushev.me/av/jellybit"
// repoRoot — корень репозитория относительно каталога пакета.
const repoRoot = "../.."
// Транспорты — тонкие обёртки над ядром: не знают друг о друге и никем из ядра
// не импортируются (CLAUDE.md, «Единое ядро, тонкие транспорты»).
var transports = map[string]bool{
"internal/httpapi": true,
"internal/tgbot": true,
}
func TestТранспортыНеЗависятДругОтДруга(t *testing.T) {
for pkg, imports := range internalImports(t) {
if !transports[pkg] {
continue
}
for _, imp := range imports {
if transports[imp] && imp != pkg {
t.Errorf("%s импортирует транспорт %s: транспорты не знают друг о друге, общая логика живёт в ядре", pkg, imp)
}
}
}
}
func TestЯдроНеЗависитОтТранспортов(t *testing.T) {
for pkg, imports := range internalImports(t) {
if transports[pkg] || pkg == "cmd/jellybit" {
continue
}
for _, imp := range imports {
if transports[imp] {
t.Errorf("%s импортирует транспорт %s: зависимость направлена не туда, ядро не знает о доставке", pkg, imp)
}
}
}
}
// lastLegacyMigration — последняя миграция, написанная до того, как конвенция
// сложилась: 0001 заводила AUTOINCREMENT и DEFAULT datetime('now'), 0006 и 0008
// как раз уводили схему на ULID и RFC 3339 и потому упоминают старую форму.
// Миграции неизменяемы, переписывать их нельзя — правило действует на новые.
const lastLegacyMigration = 8
// docs/conventions/database.md: PK — TEXT ULID через internal/ident, время
// генерирует приложение (store.Now), а не SQLite.
func TestМиграцииБезAutoincrementИСерверногоВремени(t *testing.T) {
forbidden := []struct {
re *regexp.Regexp
why string
}{
{regexp.MustCompile(`(?i)autoincrement`), "PK — TEXT ULID через internal/ident, без AUTOINCREMENT"},
{regexp.MustCompile(`(?i)default\s*\(?\s*(datetime\s*\(\s*'now'|current_timestamp)`), "время генерирует приложение через store.Now(), а не DEFAULT в схеме (fail-loud при забытой вставке)"},
}
dir := filepath.Join(repoRoot, "internal/store/migrations")
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("читаю каталог миграций: %v", err)
}
for _, e := range entries {
if e.IsDir() || migrationNumber(t, e.Name()) <= lastLegacyMigration {
continue
}
body, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatalf("читаю %s: %v", e.Name(), err)
}
for _, f := range forbidden {
if loc := f.re.FindIndex(body); loc != nil {
t.Errorf("%s: строка %d — %s", e.Name(), lineOf(body, loc[0]), f.why)
}
}
}
}
// docs/conventions/errors.md: сравнение ошибок — errors.Is/errors.As, никогда
// по тексту. errorlint ловит `err == ErrX` и приведение типа, но не матчинг
// подстрокой — его ловим здесь.
func TestОшибкиНеМатчатсяПоТексту(t *testing.T) {
re := regexp.MustCompile(`(strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\([^)]*\.Error\(\)|\.Error\(\)\s*==)`)
for _, path := range goFiles(t) {
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("читаю %s: %v", path, err)
}
if loc := re.FindIndex(body); loc != nil {
rel, _ := filepath.Rel(repoRoot, path)
t.Errorf("%s:%d — ошибку матчим через errors.Is/errors.As, а не по тексту сообщения", rel, lineOf(body, loc[0]))
}
}
}
// internalImports возвращает карту «пакет репозитория → его внутренние импорты»
// (пути относительно корня модуля).
func internalImports(t *testing.T) map[string][]string {
t.Helper()
out := map[string][]string{}
fset := token.NewFileSet()
for _, path := range goFiles(t) {
f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("разбираю %s: %v", path, err)
}
rel, err := filepath.Rel(repoRoot, filepath.Dir(path))
if err != nil {
t.Fatalf("отношу путь %s: %v", path, err)
}
for _, imp := range f.Imports {
p := strings.Trim(imp.Path.Value, `"`)
if after, ok := strings.CutPrefix(p, modulePath+"/"); ok {
out[rel] = append(out[rel], after)
}
}
}
return out
}
// goFiles — все нетестовые .go файлы репозитория (без tmp и вендорных каталогов).
func goFiles(t *testing.T) []string {
t.Helper()
var files []string
err := filepath.WalkDir(repoRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case "tmp", "vendor", ".git", "node_modules":
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") {
files = append(files, path)
}
return nil
})
if err != nil {
t.Fatalf("обхожу репозиторий: %v", err)
}
return files
}
// migrationNumber достаёт числовой префикс имени миграции (0009_… → 9).
func migrationNumber(t *testing.T, name string) int {
t.Helper()
prefix, _, ok := strings.Cut(name, "_")
if !ok {
t.Fatalf("имя миграции без числового префикса: %s", name)
}
n, err := strconv.Atoi(prefix)
if err != nil {
t.Fatalf("нечисловой префикс миграции %s: %v", name, err)
}
return n
}
func lineOf(body []byte, offset int) int {
return 1 + strings.Count(string(body[:offset]), "\n")
}
+33 -6
View File
@@ -38,6 +38,12 @@ type General struct {
// Пусто → UTC. База зон встроена (time/tzdata), поэтому имя валидируется // Пусто → UTC. База зон встроена (time/tzdata), поэтому имя валидируется
// одинаково на любом хосте (см. DisplayLocation). // одинаково на любом хосте (см. DisplayLocation).
Timezone string `toml:"timezone"` Timezone string `toml:"timezone"`
// Language — язык локализованного вывода: `title` от LLM-детектора и
// режиссёр/локаль запросов к метабазам. Допустимо: "ru" | "en", пусто → "en"
// (см. ContentLanguage). `original_title` от настройки не зависит — всегда на
// языке оригинала. Диалект локали провайдера (напр. TMDB ru-RU/en-US) выводит
// сам провайдер из этого кода.
Language string `toml:"language"`
} }
// QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок. // QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок.
@@ -55,7 +61,7 @@ type QBittorrent struct {
PathMap map[string]string `toml:"path_map"` PathMap map[string]string `toml:"path_map"`
} }
// Paths — хост-пути медиа-песочницы (см. docs/specs/architecture.md). // Paths — хост-пути медиа-песочницы (см. docs/architecture.md).
type Paths struct { type Paths struct {
Downloads string `toml:"downloads"` Downloads string `toml:"downloads"`
Movies string `toml:"movies"` Movies string `toml:"movies"`
@@ -86,14 +92,14 @@ type Metadata struct {
} }
// MetadataProvider — настройки одного провайдера метаданных. У keyless-баз // MetadataProvider — настройки одного провайдера метаданных. У keyless-баз
// (TVMaze) поле api_key не используется; language учитывает только TMDB // (TVMaze) поле api_key не используется. Язык названий задаёт глобальный
// (локаль возвращаемых названий, дефолт ru-RU). // [general].language (локаль провайдера выводится из него), отдельной настройки
// у провайдера нет.
type MetadataProvider struct { type MetadataProvider struct {
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
APIKey string `toml:"api_key"` APIKey string `toml:"api_key"`
Proxy string `toml:"proxy"` Proxy string `toml:"proxy"`
Timeout Duration `toml:"timeout"` Timeout Duration `toml:"timeout"`
Language string `toml:"language"`
} }
// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается // Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
@@ -181,11 +187,22 @@ func (c *Config) DisplayLocation() (*time.Location, error) {
return loc, nil return loc, nil
} }
// ContentLanguage возвращает язык локализованного вывода абстрактным кодом
// ("ru" | "en"); пусто → "en". Диалект локали конкретного провайдера (ru-RU,
// en-US, …) выводит сам провайдер из этого кода — здесь его не знаем. Значение
// провалидировано на старте (validate): либо пусто, либо один из кодов.
func (c *Config) ContentLanguage() string {
if c.General.Language == "" {
return "en"
}
return c.General.Language
}
// Default возвращает конфиг с разумными умолчаниями; значения из файла // Default возвращает конфиг с разумными умолчаниями; значения из файла
// перекрывают их при загрузке. // перекрывают их при загрузке.
func Default() *Config { func Default() *Config {
return &Config{ return &Config{
General: General{Timezone: "UTC"}, General: General{Timezone: "UTC", Language: "en"},
QBittorrent: QBittorrent{ QBittorrent: QBittorrent{
URL: "http://qbit:8989", URL: "http://qbit:8989",
Username: "admin", Username: "admin",
@@ -204,7 +221,7 @@ func Default() *Config {
MaxRetries: 3, MaxRetries: 3,
}, },
Metadata: Metadata{ Metadata: Metadata{
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second), Language: "ru-RU"}, TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)}, TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
}, },
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)}, Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
@@ -264,6 +281,16 @@ func (c *Config) validate() error {
if _, err := c.DisplayLocation(); err != nil { if _, err := c.DisplayLocation(); err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
// Язык локализованного вывода: пусто (→ en) или один из кодов. Fail-fast,
// как llm.type: мусорное значение не должно молча дефолтить. Множество
// {ru, en} — канон; при добавлении кода синхронно расширь мапперы
// metadata.tmdbLocale, metadata.tvdbLocale и recognize.languageDirective,
// иначе новый язык молча даст английский вывод.
switch c.General.Language {
case "", "ru", "en":
default:
errs = append(errs, fmt.Errorf("unsupported general.language %q (supported: ru, en)", c.General.Language))
}
// Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги. // Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги.
for _, p := range []struct{ name, path string }{ for _, p := range []struct{ name, path string }{
+25
View File
@@ -74,6 +74,30 @@ func TestDisplayLocation(t *testing.T) {
} }
} }
// TestContentLanguage — язык вывода: дефолт en (в т.ч. из Default()), пусто → en,
// коды ru/en возвращаются как есть.
func TestContentLanguage(t *testing.T) {
if got := Default().ContentLanguage(); got != "en" {
t.Fatalf("Default().ContentLanguage() = %q, want en", got)
}
cases := map[string]string{"": "en", "ru": "ru", "en": "en"}
for in, want := range cases {
c := &Config{General: General{Language: in}}
if got := c.ContentLanguage(); got != want {
t.Errorf("ContentLanguage(%q) = %q, want %q", in, got, want)
}
}
}
// TestValidate_EmptyLanguageOK — пустой язык валиден (нормализуется в en аксессором).
func TestValidate_EmptyLanguageOK(t *testing.T) {
c := validCfg(t)
c.General.Language = ""
if err := c.validate(); err != nil {
t.Fatalf("пустой general.language должен быть валиден, got %v", err)
}
}
func TestValidate_Errors(t *testing.T) { func TestValidate_Errors(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -94,6 +118,7 @@ func TestValidate_Errors(t *testing.T) {
{"jellyfin enabled no key", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "http://j"; c.Jellyfin.APIKey = "" }, "jellyfin.api_key"}, {"jellyfin enabled no key", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "http://j"; c.Jellyfin.APIKey = "" }, "jellyfin.api_key"},
{"telegram enabled no token", func(c *Config) { c.Telegram.Enabled = true }, "telegram.token"}, {"telegram enabled no token", func(c *Config) { c.Telegram.Enabled = true }, "telegram.token"},
{"bad timezone", func(c *Config) { c.General.Timezone = "Mars/Phobos" }, "general.timezone"}, {"bad timezone", func(c *Config) { c.General.Timezone = "Mars/Phobos" }, "general.timezone"},
{"bad language", func(c *Config) { c.General.Language = "de" }, "general.language"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
+94 -6
View File
@@ -2,7 +2,7 @@ package httpapi
import ( import (
"context" "context"
"io" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -50,7 +50,7 @@ func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
func testRouterAction(t *testing.T, r stubReader, rv Reviewer, cmd Commander, lv stubLive) http.Handler { func testRouterAction(t *testing.T, r stubReader, rv Reviewer, cmd Commander, lv stubLive) http.Handler {
t.Helper() t.Helper()
h, err := NewRouter(Deps{ h, err := NewRouter(Deps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Logger: slog.New(slog.DiscardHandler),
Reader: r, Reader: r,
Reviewer: rv, Reviewer: rv,
Commander: cmd, Commander: cmd,
@@ -338,8 +338,9 @@ func TestSourceSwapUpdatesActionBarOOB(t *testing.T) {
}) })
} }
// TestRetryListShowsProgress: retry из списка → карточка downloading с // TestRetryListShowsProgress: retry из списка → карточка downloading с живым
// прогресс-поллером. // прогрессом и самообновлением карточки (опрашивает себя карточка, а не
// вложенный блок прогресса).
func TestRetryListShowsProgress(t *testing.T) { func TestRetryListShowsProgress(t *testing.T) {
dl := dlState(store.StateDownloading) dl := dlState(store.StateDownloading)
lv := stubLive{m: map[string]worker.Live{"ihswap": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}} lv := stubLive{m: map[string]worker.Live{"ihswap": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
@@ -349,7 +350,94 @@ func TestRetryListShowsProgress(t *testing.T) {
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("retry (htmx) = %d, want 200", rr.Code) t.Fatalf("retry (htmx) = %d, want 200", rr.Code)
} }
if !strings.Contains(rr.Body.String(), "/fragments/downloads/"+testULID+"/progress") { body := rr.Body.String()
t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String()) for _, want := range []string{"width:42%", "/fragments/downloads/" + testULID + "/card"} {
if !strings.Contains(body, want) {
t.Errorf("карточка downloading без %q: %s", want, body)
}
}
}
// TestActionBarNamesReasonWithoutPreview: при пустом предпросмотре панель
// действий печатает записанную причину, а не общее «Подтверди источник» —
// иначе экран советует подтвердить уже подтверждённое и молчит о настоящей
// причине (см. spec review, «Панель действий при пустом предпросмотре»).
func TestActionBarNamesReasonWithoutPreview(t *testing.T) {
t.Run("причина показа старше записанной", func(t *testing.T) {
rd := reviewDataWithSource(false)
rd.PreviewError = `layout: имя не помещается: "Очень длинное" — 400 байт при пределе 255`
rd.Download.ErrorMsg = store.NullString("устаревшая причина прошлого перехода")
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
if rr.Code != http.StatusOK {
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "не помещается") {
t.Errorf("панель не назвала причину: %s", body)
}
if strings.Contains(body, "Подтверди источник") {
t.Errorf("панель печатает общий текст вместо причины: %s", body)
}
if strings.Contains(body, "устаревшая") {
t.Errorf("записанная причина перебила посчитанную на показе: %s", body)
}
})
t.Run("записанная причина, когда посчитанной нет", func(t *testing.T) {
rd := reviewDataWithSource(false)
rd.Download.ErrorMsg = store.NullString("целевой файл уже существует")
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
if !strings.Contains(rr.Body.String(), "уже существует") {
t.Errorf("панель не назвала записанную причину: %s", rr.Body.String())
}
})
t.Run("причины нет → прежний общий текст", func(t *testing.T) {
rd := reviewDataWithSource(false)
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
if rr.Code != http.StatusOK {
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), "Подтверди источник") {
t.Errorf("без причины ожидался общий текст: %s", rr.Body.String())
}
})
}
// TestActionSwapErrorKeepsSwapRoot: действие человека, упавшее на чтении задачи,
// отвечает 200 и фрагментом с корнем своей поверхности — иначе своп унёс бы
// якорь (#card-{id} у списка, #download-main у страницы) и следующие действия
// целились бы в несуществующий узел. Ретрая у действия нет, поэтому уровень лога
// здесь ERROR, а не WARN, как у повторяющегося тика.
func TestActionSwapErrorKeepsSwapRoot(t *testing.T) {
cases := []struct{ surface, root string }{
{"list", `id="card-` + testULID + `"`},
{"download", `id="download-main"`},
}
for _, c := range cases {
rd := stubReader{getErr: errors.New("db is gone")}
h := testRouterAction(t, rd, actionReviewer{}, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", url.Values{"surface": {c.surface}}, true)
if rr.Code != http.StatusOK {
t.Errorf("surface=%s: status = %d, want 200", c.surface, rr.Code)
continue
}
body := rr.Body.String()
if !strings.Contains(body, c.root) {
t.Errorf("surface=%s: фрагмент отказа без корня %s:\n%s", c.surface, c.root, body)
}
if strings.Contains(body, "hx-trigger") {
t.Errorf("surface=%s: фрагмент отказа не самозавершается:\n%s", c.surface, body)
}
} }
} }
+289
View File
@@ -0,0 +1,289 @@
package httpapi
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// maxBulkDelete — верхний предел числа загрузок в одной пачке. Подтверждение,
// перечисляющее больше, человек не читает — то есть перестаёт быть
// подтверждением; плюс один синхронный запрос упирается в столько же
// последовательных вызовов qBittorrent. Число названо на самой странице выбора:
// предел, о котором узнают только из отказа, отнимает уже сделанную работу.
const maxBulkDelete = 20
// bulkFailThreshold — сколько подряд идущих отказов внешнего сервиса
// прекращают проход. Удаление снимает библиотечные ссылки раньше, чем сносит
// раздачу: при лежащем qBittorrent каждая единица успевает выполнить
// необратимый локальный шаг и упасть на внешнем, оставив тайтл без раскладки и
// не освободив места. Счётчик сбрасывается на успехе — одиночная сетевая
// ошибка пачку не рвёт.
const bulkFailThreshold = 3
// bulkBudget — потолок времени на один проход пачки. Удаление держит общий
// замок воркера на всё время обращения к qBittorrent, поэтому медленно, но
// успешно отвечающий сосед останавливает фоновую работу целиком, а порог
// отказов такого не ловит — он считает только ошибки. Проверяется МЕЖДУ
// единицами, а не отменой контекста: начатое удаление обрывать нельзя, иначе
// оно встанет между снятием библиотечных ссылок и сносом раздачи.
//
// Переменная, а не константа, ровно по одной причине: тест укорачивает её —
// иначе проверка потолка стоила бы двух минут прогона.
var bulkBudget = 2 * time.Minute
// Отказы разбора пачки. Текст — публичного канала: он показывается человеку
// как есть, как у прочих sentinel'ов транспорта. Трансляция в статус и
// сообщение живёт в единой точке `classifyErr`, а не рядом.
var (
errBatchEmpty = errors.New("ни одна загрузка не выбрана")
errBatchTooLarge = errors.New("за один раз можно удалить не больше " +
strconv.Itoa(maxBulkDelete) + " загрузок")
errBatchBadID = errors.New("некорректный идентификатор загрузки — запрос отклонён целиком")
errBatchForm = errors.New("форма запроса не разобрана — запрос отклонён целиком")
)
// bulkRow — строка загрузки на любом из трёх экранов группового удаления.
type bulkRow struct {
ID string
Title string
State string
Selected bool // отметка сохранена при возврате отказа
LastCopy bool // orphaned: библиотечная ссылка осталась последней копией
Missing bool // записи в хранилище нет
Unread bool // состояние прочитать не удалось (отказ хранилища)
Reason string // причина отказа (только на экране результата)
}
// bulkSelectView — страница выбора (`GET /delete`) и она же ответ на отказ
// разбора: отметки при этом сохраняются, иначе проверка стирает всю работу.
type bulkSelectView struct {
Error string
Max int
Rows []bulkRow
}
// bulkConfirmView — страница подтверждения: выбранные названы поимённо.
type bulkConfirmView struct {
Rows []bulkRow
}
// bulkResultView — отчёт: обе половины исхода поимённо плюс остаток, если
// проход остановлен системным отказом.
type bulkResultView struct {
Deleted []bulkRow
Failed []bulkRow
Skipped []bulkRow
StopReason string
}
// handleBulkDeletePage — страница выбора. Самообновления не несёт сознательно:
// своп разметки унёс бы отметки, и человек подтвердил бы необратимое удаление
// по выбору, которого уже не видит (см. openspec/specs/web-ui).
func (s *server) handleBulkDeletePage(w http.ResponseWriter, r *http.Request) {
s.renderBulkSelect(w, r, "", nil)
}
// renderBulkSelect отрисовывает страницу выбора, помечая отмеченными те строки,
// чьи идентификаторы человек уже выбрал (selected). Общий путь для чистого
// открытия страницы и для любого отказа разбора.
func (s *server) renderBulkSelect(w http.ResponseWriter, r *http.Request, msg string, selected []string) {
ds, err := s.deps.Reader.ListDeletableDownloads(r.Context())
if err != nil {
s.deps.Logger.Error("list deletable downloads", "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
mark := make(map[string]bool, len(selected))
for _, id := range selected {
mark[id] = true
}
view := bulkSelectView{Error: msg, Max: maxBulkDelete}
for _, d := range ds {
view.Rows = append(view.Rows, bulkRow{
ID: d.ID,
Title: downloadTitle(d),
State: string(d.State),
Selected: mark[d.ID],
LastCopy: d.State == store.StateOrphaned,
})
}
s.render(w, "delete.html", view)
}
// handleBulkDeleteConfirm — экран подтверждения. Ничего не меняет: разбирает
// вход, читает выбранные загрузки и называет каждую поимённо.
func (s *server) handleBulkDeleteConfirm(w http.ResponseWriter, r *http.Request) {
ids, err := s.parseBulkBatch(r)
if err != nil {
s.renderBulkSelect(w, r, bulkErrMsg(err), ids)
return
}
s.render(w, "delete_confirm.html", bulkConfirmView{Rows: s.bulkRows(r.Context(), ids)})
}
// handleBulkDelete — исполнение пачки. Признак подтверждения проверяется ДО
// разбора и до единого вызова удаления: подтверждение — условие операции, а не
// украшение экрана.
func (s *server) handleBulkDelete(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil || r.PostForm.Get("confirm") != "1" {
s.renderBulkSelect(w, r, "Удаление уходит только со страницы подтверждения.", nil)
return
}
// Исполняющий запрос — самостоятельная входная граница: идентификаторы
// приходят формой заново, состояния между шагами сервис не хранит.
ids, err := s.parseBulkBatch(r)
if err != nil {
s.renderBulkSelect(w, r, bulkErrMsg(err), ids)
return
}
// Контекст исполнения отвязан от запроса: обрыв связи не вправе оборвать
// необратимую операцию на середине — в том числе внутри одной загрузки,
// между снятием библиотечных ссылок и сносом раздачи. Строки отчёта читаются
// тем же контекстом: собранные отменённым, они превратили бы весь отчёт в
// «загрузка не найдена» ровно там, где удаление идёт штатно.
ctx := context.WithoutCancel(r.Context())
rows := s.bulkRows(ctx, ids)
var res bulkResultView
streak := 0
deadline := store.Now().Add(bulkBudget)
for i, row := range rows {
if res.StopReason != "" {
res.Skipped = append(res.Skipped, rows[i])
continue
}
if store.Now().After(deadline) {
res.StopReason = "Проход занял дольше отведённого времени и остановлен: " +
"пока идёт пачка, остальная работа сервиса ждёт."
res.Skipped = append(res.Skipped, rows[i])
continue
}
err := s.deps.Reviewer.Delete(ctx, row.ID)
if err == nil {
streak = 0
res.Deleted = append(res.Deleted, row)
continue
}
row.Reason = userErr(r, err, row.ID)
res.Failed = append(res.Failed, row)
// Конфликт состояния и отсутствие записи — про саму задачу, а не про
// доступность соседа: счётчик системных отказов они не двигают.
if errors.Is(err, worker.ErrConflict) || errors.Is(err, store.ErrNotFound) {
continue
}
streak++
if streak >= bulkFailThreshold {
res.StopReason = "Внешний сервис отказывает подряд — проход остановлен, " +
"чтобы не снимать раскладку у остальных без освобождения места."
}
}
// Исход каждой единицы поимённо: ответ мог не дойти (вкладку закрыли), и
// журнал — единственное, по чему потом видно, что снесено, что отказало и до
// чего проход не дошёл. На воркер полагаться нельзя: отказы по конфликту и
// отсутствию записи он пишет на DEBUG.
for _, row := range res.Deleted {
s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "deleted")
}
for _, row := range res.Failed {
s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "failed",
"reason", row.Reason)
}
for _, row := range res.Skipped {
s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "skipped")
}
s.deps.Logger.Info("bulk delete finished",
"requested", len(rows), "deleted", len(res.Deleted),
"failed", len(res.Failed), "skipped", len(res.Skipped),
"stopped", res.StopReason != "")
s.render(w, "delete_result.html", res)
}
// bulkRows читает выбранные загрузки для показа поимённо. Идентификатор без
// записи в хранилище не выбрасывается молча — он идёт своей строкой: человек
// подтверждает пачку, и она обязана совпадать с тем, что он выбрал.
func (s *server) bulkRows(ctx context.Context, ids []string) []bulkRow {
rows := make([]bulkRow, 0, len(ids))
for _, id := range ids {
d, err := s.deps.Reader.GetDownload(ctx, id)
switch {
case errors.Is(err, store.ErrNotFound):
rows = append(rows, bulkRow{ID: id, Missing: true, Title: "загрузка не найдена"})
continue
case err != nil || d == nil:
// Отказ хранилища — это НЕ «записи нет». Выдав одно за другое, экран
// сказал бы «удалять нечего» о загрузке, которую пачка снесёт
// по-настоящему, и для orphaned унёс бы отметку последней копии —
// единственный оставшийся предохранитель. Приватный канал: пишем
// здесь, потому что выше эта ошибка не всплывает.
s.deps.Logger.Error("bulk delete: read download", "download_id", id, "error", err)
rows = append(rows, bulkRow{ID: id, Unread: true, Title: "состояние прочитать не удалось"})
continue
}
rows = append(rows, bulkRow{
ID: d.ID,
Title: downloadTitle(*d),
State: string(d.State),
LastCopy: d.State == store.StateOrphaned,
})
}
return rows
}
// parseBulkBatch разбирает пачку идентификаторов с формы. Проверки одинаковы на
// обеих границах — подтверждения и исполнения. Возвращает разобранные
// идентификаторы даже вместе с отказом: страница выбора возвращает по ним
// отметки, чтобы отказ не стирал проделанную работу.
func (s *server) parseBulkBatch(r *http.Request) ([]string, error) {
if err := r.ParseForm(); err != nil {
// Приватный канал: выше эта ошибка не всплывает, а человеку про
// идентификаторы говорить нечего — тело не прочиталось целиком.
s.deps.Logger.Error("bulk delete: parse form", "error", err)
return nil, errBatchForm
}
// Только тело: признак подтверждения и пачка приходят формой, и принимать
// их из строки запроса значит принимать подтверждение оттуда, откуда
// требование его не заказывало.
raw := r.PostForm["id"]
seen := make(map[string]bool, len(raw))
ids := make([]string, 0, len(raw))
for _, v := range raw {
id, err := ident.Parse(v)
if err != nil {
// Молча пропустить нельзя: человек подтвердил удаление поимённо, и
// выброшенный идентификатор развёл бы подтверждённое с исполненным.
return ids, errBatchBadID
}
if seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, errBatchEmpty
}
if len(ids) > maxBulkDelete {
return ids, errBatchTooLarge
}
return ids, nil
}
// bulkErrMsg — сообщение человеку об отказе разбора. Сам текст берётся из
// единой точки трансляции (`classifyErr`); здесь добавляется только подсказка,
// что делать дальше, — она осмысленна ровно на этой странице.
func bulkErrMsg(err error) string {
_, msg := classifyErr(err)
if errors.Is(err, errBatchTooLarge) {
return msg + ". Отметки сохранены — сними лишние."
}
return msg + "."
}
+611
View File
@@ -0,0 +1,611 @@
package httpapi
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// Валидные lowercase-ULID для форм (parseBulkBatch прогоняет через ident.Parse).
const (
bid1 = "01arz3ndektsv4rrffq69g5fa1"
bid2 = "01arz3ndektsv4rrffq69g5fa2"
bid3 = "01arz3ndektsv4rrffq69g5fa3"
bid4 = "01arz3ndektsv4rrffq69g5fa4"
)
// bulkReviewer — Reviewer, который считает вызовы удаления и умеет отказать по
// конкретному идентификатору. Указатель: тест проверяет «ни одного вызова», а
// значение-копия этого не покажет.
type bulkReviewer struct {
stubReviewer
calls *[]string
errs map[string]error
allErr error // отказ по любому идентификатору (лежащий внешний сервис)
sleep time.Duration // медленный, но исправный сосед
}
func (b bulkReviewer) Delete(ctx context.Context, id string) error {
*b.calls = append(*b.calls, id)
// Контекст исполнения обязан пережить отмену запроса: необратимую операцию
// нельзя обрывать на середине. Проверяем во всех тестах, а не только в том,
// что об этом, — гарантия одна на все пути.
if err := ctx.Err(); err != nil {
return fmt.Errorf("bulk delete stub: %w", err)
}
time.Sleep(b.sleep)
if b.allErr != nil {
return b.allErr
}
return b.errs[id]
}
func newBulkReviewer(errs map[string]error) (bulkReviewer, *[]string) {
calls := &[]string{}
return bulkReviewer{calls: calls, errs: errs}, calls
}
func dl(id string, st store.State, title string) store.Download {
return store.Download{ID: id, State: st, DisplayName: title}
}
// byID собирает карту для поштучного чтения на экранах подтверждения и отчёта.
func byID(ds ...store.Download) map[string]store.Download {
m := make(map[string]store.Download, len(ds))
for _, d := range ds {
m[d.ID] = d
}
return m
}
// postCancelled отправляет POST с уже отменённым контекстом запроса — так
// выглядит закрытая вкладка или оборванная связь на середине пачки.
func postCancelled(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
ctx, cancel := context.WithCancel(req.Context())
req = req.WithContext(ctx)
cancel()
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
func idForm(ids ...string) url.Values {
f := url.Values{}
for _, id := range ids {
f.Add("id", id)
}
return f
}
// П1: страница показывает строки только тех загрузок, для которых удаление
// разрешено поштучно. Читатель отдаёт задачи во всех состояниях — на странице
// оказываются ровно разрешённые.
func TestBulkDeletePageShowsOnlyDeletable(t *testing.T) {
all := []store.State{
store.StateCatched, store.StateDownloading, store.StateCompleted,
store.StateRecognizing, store.StateReview, store.StateLinking,
store.StateDone, store.StateDeferred, store.StateStuck,
store.StateFailed, store.StateCancelled, store.StateReverted,
store.StateTargetMissing, store.StateOrphaned, store.StateDeleted,
}
// Читатель отдаёт всё подряд — отбирает страница по домену, а не тест.
var rows []store.Download
for i, st := range all {
if st.CanDelete() {
rows = append(rows, dl(fmt.Sprintf("%026d", i), st, "задача "+string(st)))
}
}
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{deletable: rows}, rv, stubCommander{}, stubLive{})
body := get(t, h, "/delete").Body.String()
for _, st := range all {
marker := "задача " + string(st)
if got := strings.Contains(body, marker); got != st.CanDelete() {
t.Errorf("%s: строка на странице=%v, CanDelete=%v", st, got, st.CanDelete())
}
}
}
// Страница не опрашивает сервер: своп разметки стёр бы отметки, и человек
// подтвердил бы необратимое удаление по выбору, которого уже не видит.
func TestBulkDeletePageDoesNotSelfPoll(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t,
stubReader{deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")}},
rv, stubCommander{}, stubLive{})
body := get(t, h, "/delete").Body.String()
if strings.Contains(body, `hx-trigger="every`) {
t.Error("страница выбора не должна самообновляться")
}
// Предел пачки назван до отправки — иначе отказ по нему отнимает работу.
if !strings.Contains(body, strconv.Itoa(maxBulkDelete)) {
t.Errorf("предел пачки не назван на странице:\n%s", body)
}
}
// Пустое состояние: разрешённых нет — удаление не предлагается.
func TestBulkDeletePageEmpty(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{}, rv, stubCommander{}, stubLive{})
body := get(t, h, "/delete").Body.String()
if strings.Contains(body, `action="/ui/delete/confirm"`) {
t.Error("на пустой странице не должно быть формы удаления")
}
if !strings.Contains(body, "Удалять нечего") {
t.Errorf("нет пустого состояния:\n%s", body)
}
}
// П2 (первая половина): подтверждение называет каждую выбранную поимённо и не
// делает ни одного вызова удаления.
func TestBulkConfirmNamesRowsAndDeletesNothing(t *testing.T) {
rv, calls := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateOrphaned, "Фарго"),
)}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete/confirm", idForm(bid1, bid2), false).Body.String()
for _, want := range []string{"Дюна", "Фарго", bid1, bid2} {
if !strings.Contains(body, want) {
t.Errorf("подтверждение не называет %q:\n%s", want, body)
}
}
if len(*calls) != 0 {
t.Errorf("подтверждение не должно удалять, вызовы: %v", *calls)
}
}
// Строка orphaned на подтверждении предупреждает о последней копии данных: гард
// последней копии в удалении выключен сознательно, и осведомлённость человека —
// единственный оставшийся предохранитель.
func TestBulkConfirmWarnsAboutLastCopy(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateOrphaned, "Фарго"),
)}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String()
if !strings.Contains(body, "последняя копия данных") {
t.Errorf("нет предупреждения о последней копии:\n%s", body)
}
}
// П2 (вторая половина): без признака подтверждения не удаляется ничего.
func TestBulkDeleteWithoutConfirmDeletesNothing(t *testing.T) {
rv, calls := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
)}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete", idForm(bid1), false).Body.String()
if len(*calls) != 0 {
t.Errorf("без подтверждения не должно быть вызовов удаления: %v", *calls)
}
if !strings.Contains(body, "страницы подтверждения") {
t.Errorf("отказ не объяснён:\n%s", body)
}
}
// Гарды разбора одинаковы на обеих границах: исполняющий запрос получает
// идентификаторы формой заново и на проверки подтверждения опираться не вправе.
func TestBulkBatchGuardsOnBothBoundaries(t *testing.T) {
over := make([]string, 0, maxBulkDelete+1)
for i := range maxBulkDelete + 1 {
over = append(over, fmt.Sprintf("%026d", i))
}
cases := []struct {
name string
form url.Values
want string
}{
{"неразобранный идентификатор", idForm(bid1, "не-ulid"), "некорректный идентификатор"},
{"пачка сверх предела", idForm(over...), "не больше"},
{"пустой набор", url.Values{}, "ни одна загрузка не выбрана"},
}
for _, c := range cases {
for _, path := range []string{"/ui/delete/confirm", "/ui/delete"} {
t.Run(c.name+" "+path, func(t *testing.T) {
rv, calls := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{
deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")},
byID: byID(dl(bid1, store.StateDone, "Дюна")),
}, rv, stubCommander{}, stubLive{})
form := url.Values{}
for k, v := range c.form {
form[k] = v
}
if path == "/ui/delete" {
form.Set("confirm", "1")
}
body := post(t, h, path, form, false).Body.String()
if len(*calls) != 0 {
t.Errorf("отказ разбора не должен удалять: %v", *calls)
}
if !strings.Contains(body, c.want) {
t.Errorf("нет объяснения %q:\n%s", c.want, body)
}
})
}
}
}
// Отказ по пределу возвращает страницу выбора с сохранёнными отметками: иначе
// проверка отнимает всю проделанную человеком работу.
func TestBulkOverLimitKeepsSelection(t *testing.T) {
rows := []store.Download{dl(bid1, store.StateDone, "Дюна")}
over := []string{bid1}
for i := range maxBulkDelete {
over = append(over, fmt.Sprintf("%026d", i))
}
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{deletable: rows}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete/confirm", idForm(over...), false).Body.String()
if !strings.Contains(body, `value="`+bid1+`" checked`) {
t.Errorf("отметка выбора не сохранена:\n%s", body)
}
}
// Дубликаты в пачке схлопываются: повторный вызов по той же задаче дал бы
// ложный конфликт во второй строке отчёта.
func TestBulkDeleteCollapsesDuplicates(t *testing.T) {
rv, calls := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid1, bid1)
form.Set("confirm", "1")
post(t, h, "/ui/delete", form, false)
if len(*calls) != 1 {
t.Errorf("дубликаты не схлопнуты: %v", *calls)
}
}
// П3: отказ на одной загрузке не отменяет остальных, отчёт называет обе
// половины поимённо.
func TestBulkDeletePartialFailure(t *testing.T) {
rv, calls := newBulkReviewer(map[string]error{
bid2: errors.New("qbittorrent: connection refused"),
})
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2, bid3)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != 3 {
t.Fatalf("удаление должно уйти по всем трём: %v", *calls)
}
for _, want := range []string{"Дюна", "Фарго", "Оппенгеймер", "Удалено — 2", "Отказ — 1"} {
if !strings.Contains(body, want) {
t.Errorf("отчёт не называет %q:\n%s", want, body)
}
}
// Сырой текст ошибки внешнего сервиса наружу не идёт — только публичный канал.
if strings.Contains(body, "connection refused") {
t.Errorf("сырая ошибка просочилась в разметку:\n%s", body)
}
}
// П4: групповой путь прав поштучного не расширяет — недопустимое состояние
// отклоняется тем же конфликтом, остальные выбранные удаляются.
func TestBulkDeleteConflictDoesNotWidenRights(t *testing.T) {
rv, calls := newBulkReviewer(map[string]error{
bid2: fmt.Errorf("delete: download in state downloading: %w", worker.ErrConflict),
})
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDownloading, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2, bid3)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != 3 {
t.Fatalf("допуск проверяет ядро — звать надо все три: %v", *calls)
}
if !strings.Contains(body, "Удалено — 2") || !strings.Contains(body, "Отказ — 1") {
t.Errorf("отчёт не разделил исходы:\n%s", body)
}
}
// Все удалены — отчёт называет обе загрузки и отказов не содержит.
func TestBulkDeleteAllSucceed(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateOrphaned, "Фарго"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if !strings.Contains(body, "Удалено — 2") || strings.Contains(body, "Отказ — ") {
t.Errorf("ожидались две удалённые без отказов:\n%s", body)
}
}
// Идентификатор без записи в хранилище назван строкой и на подтверждении, и в
// отчёте: молча выброшенный, он развёл бы подтверждённое с исполненным.
func TestBulkMissingDownloadIsNamed(t *testing.T) {
rv, _ := newBulkReviewer(map[string]error{
bid2: fmt.Errorf("delete: %w", store.ErrNotFound),
})
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid3, store.StateDone, "Оппенгеймер"),
)}, rv, stubCommander{}, stubLive{})
confirm := post(t, h, "/ui/delete/confirm", idForm(bid1, bid2, bid3), false).Body.String()
if !strings.Contains(confirm, bid2) || !strings.Contains(confirm, "не найдена") {
t.Errorf("подтверждение не назвало ненайденную загрузку:\n%s", confirm)
}
form := idForm(bid1, bid2, bid3)
form.Set("confirm", "1")
res := post(t, h, "/ui/delete", form, false).Body.String()
if !strings.Contains(res, bid2) {
t.Errorf("отчёт не назвал ненайденную загрузку:\n%s", res)
}
if !strings.Contains(res, "Удалено — 2") {
t.Errorf("остальные должны быть удалены:\n%s", res)
}
}
// Системный отказ останавливает пачку: удаление снимает библиотечные ссылки
// раньше, чем сносит раздачу, поэтому при лежащем qBittorrent проход без
// остановки оставил бы без раскладки все выбранные тайтлы разом.
func TestBulkDeleteStopsOnConsecutiveSystemFailures(t *testing.T) {
calls := &[]string{}
rv := bulkReviewer{calls: calls, allErr: errors.New("qbittorrent: unreachable")}
ids := []string{bid1, bid2, bid3, bid4}
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
dl(bid4, store.StateDone, "Интерстеллар"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(ids...)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != bulkFailThreshold {
t.Fatalf("проход должен остановиться после %d отказов, вызовов: %v",
bulkFailThreshold, *calls)
}
if !strings.Contains(body, "Не выполнено — 1") {
t.Errorf("остаток пачки не назван невыполненным:\n%s", body)
}
if !strings.Contains(body, "проход остановлен") {
t.Errorf("причина остановки не названа:\n%s", body)
}
}
// Одиночный отказ пачку не рвёт: счётчик подряд идущих отказов сбрасывается на
// каждом успехе, иначе случайная сетевая ошибка обрывала бы всю уборку.
func TestBulkDeleteSingleFailureDoesNotStop(t *testing.T) {
rv, calls := newBulkReviewer(map[string]error{
bid2: errors.New("qbittorrent: temporary"),
})
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
dl(bid4, store.StateDone, "Интерстеллар"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2, bid3, bid4)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != 4 {
t.Fatalf("одиночный отказ не должен останавливать проход: %v", *calls)
}
if strings.Contains(body, "Не выполнено") {
t.Errorf("остановки быть не должно:\n%s", body)
}
}
// Отмена запроса не прекращает необратимую операцию: закрытая вкладка не вправе
// оборвать пачку на середине, в том числе внутри одной загрузки.
func TestBulkDeleteSurvivesRequestCancel(t *testing.T) {
calls := &[]string{}
// Reviewer, который проверяет, что контекст исполнения жив, хотя контекст
// запроса уже отменён.
rv := bulkReviewer{calls: calls, errs: map[string]error{}}
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2)
form.Set("confirm", "1")
rr := postCancelled(t, h, "/ui/delete", form)
if len(*calls) != 2 {
t.Fatalf("пачка должна дойти до конца при обрыве: %v", *calls)
}
if !strings.Contains(rr.Body.String(), "Удалено — 2") {
t.Errorf("исход не собран:\n%s", rr.Body.String())
}
}
// Формы страниц удаления работают без JavaScript: обычный POST с рабочим action
// и никаких hx-атрибутов на пути к необратимому действию.
func TestBulkDeleteWorksWithoutJS(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{
deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")},
byID: byID(dl(bid1, store.StateDone, "Дюна")),
}, rv, stubCommander{}, stubLive{})
sel := get(t, h, "/delete").Body.String()
if !strings.Contains(sel, `<form method="post" action="/ui/delete/confirm">`) {
t.Errorf("страница выбора должна нести обычную POST-форму:\n%s", sel)
}
conf := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String()
if !strings.Contains(conf, `action="/ui/delete"`) || !strings.Contains(conf, `name="confirm" value="1"`) {
t.Errorf("подтверждение должно нести форму исполнения:\n%s", conf)
}
for _, page := range []string{sel, conf} {
if strings.Contains(page, "hx-post") || strings.Contains(page, "hx-get") {
t.Error("страницы группового удаления не должны зависеть от htmx")
}
}
}
// Р1: исполняется ровно подтверждённое множество — список идентификаторов, а не
// предикат «всё, что сейчас разрешено». Задача, ставшая разрешённой уже после
// показа подтверждения, в пачку не попадает.
func TestBulkDeleteRunsOnlyConfirmedSet(t *testing.T) {
rv, calls := newBulkReviewer(nil)
// Читатель отдаёт две разрешённые, но подтверждена одна.
h := testRouterAction(t, stubReader{
deletable: []store.Download{
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
},
byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
),
}, rv, stubCommander{}, stubLive{})
form := idForm(bid1)
form.Set("confirm", "1")
post(t, h, "/ui/delete", form, false)
if len(*calls) != 1 || (*calls)[0] != bid1 {
t.Errorf("удалено не подтверждённое множество: %v", *calls)
}
}
// Ссылка на страницу группового удаления есть в шапке любой страницы веб-UI.
// Без этой проверки удаление пункта из навигации закрыло бы единственный вход
// на страницу молча: сама страница жива, тесты зелёные, попасть некуда.
func TestHeaderLinksToBulkDelete(t *testing.T) {
dl := store.Download{ID: bid1, State: store.StateReview, DisplayName: "Дюна"}
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{list: []store.Download{dl}, one: &dl},
rv, stubCommander{}, stubLive{})
for _, path := range []string{"/", "/delete"} {
body := get(t, h, path).Body.String()
if !strings.Contains(body, `href="/delete"`) {
t.Errorf("%s: в шапке нет ссылки на групповое удаление:\n%s", path, body)
}
}
}
// Отказ хранилища не выдаётся за «записи нет»: строка говорит, что состояние
// прочитать не удалось, и отметка о последней копии не теряется молча. Иначе
// человек подтверждает пачку, где строка обещала «удалять нечего», а загрузка
// сносится с файлами по-настоящему.
func TestBulkConfirmDistinguishesReadFailure(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{
getErr: errors.New("database is locked"),
}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String()
if strings.Contains(body, "записи нет") || strings.Contains(body, "удалять нечего") {
t.Errorf("отказ чтения выдан за отсутствие записи:\n%s", body)
}
if !strings.Contains(body, "состояние прочитать не удалось") {
t.Errorf("отказ чтения не назван строкой:\n%s", body)
}
}
// Потолок времени останавливает проход: пока идёт пачка, общий замок ядра
// удерживается на каждом обращении к qBittorrent, и медленный (но рабочий)
// сосед иначе остановил бы фон целиком, не дав ни одного отказа.
func TestBulkDeleteStopsOnTimeBudget(t *testing.T) {
calls := &[]string{}
// Каждое удаление «идёт» дольше всего бюджета — второй единице стартовать
// уже нельзя.
// Бюджет укорочен на время теста — иначе проверка стоила бы двух минут.
orig := bulkBudget
bulkBudget = time.Millisecond
t.Cleanup(func() { bulkBudget = orig })
slow := bulkReviewer{calls: calls, sleep: 3 * time.Millisecond}
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
)}, slow, stubCommander{}, stubLive{})
form := idForm(bid1, bid2, bid3)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != 1 {
t.Fatalf("после исчерпания бюджета новых удалений быть не должно: %v", *calls)
}
if !strings.Contains(body, "Не выполнено — 2") {
t.Errorf("остаток не назван невыполненным:\n%s", body)
}
if !strings.Contains(body, "дольше отведённого времени") {
t.Errorf("причина остановки не названа:\n%s", body)
}
}
// Форматирующие символы юникода в имени раздачи не доезжают до экрана: с ними
// заголовок читается не так, как хранится, а поимённое чтение заголовков — и
// есть предохранитель необратимого группового удаления.
func TestBulkTitleStripsFormattingRunes(t *testing.T) {
const rtl = "\u202e" // RIGHT-TO-LEFT OVERRIDE
d := dl(bid1, store.StateDone, "Дюна"+rtl+"vkm.iso \U0001F468\u200D\U0001F469\u200D\U0001F467")
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{
deletable: []store.Download{d},
byID: byID(d),
}, rv, stubCommander{}, stubLive{})
pages := map[string]string{
"/delete": get(t, h, "/delete").Body.String(),
"/ui/delete/confirm": post(t, h, "/ui/delete/confirm",
idForm(bid1), false).Body.String(),
}
for path, body := range pages {
if strings.Contains(body, rtl) {
t.Errorf("%s: bidi-символ уехал в разметку дословно", path)
}
if !strings.Contains(body, "Дюна") {
t.Errorf("%s: заголовок потерялся целиком:\n%s", path, body)
}
// Составные эмодзи не рассыпаются: снимается класс bidi, а не весь Cf.
if !strings.Contains(body, "\U0001F468\u200D\U0001F469\u200D\U0001F467") {
t.Errorf("%s: соединитель составного эмодзи снят вместе с bidi:\n%s", path, body)
}
}
}
+17 -6
View File
@@ -4,7 +4,6 @@ import (
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"time"
"git.vakhrushev.me/av/jellybit/internal/naming" "git.vakhrushev.me/av/jellybit/internal/naming"
"git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/recognize"
@@ -22,7 +21,8 @@ type downloadDetailView struct {
Infohashes []string // все хеши загрузки (блок «Информация о торренте») Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
Context string Context string
State string State string
SelfPoll bool // catched → страница сама опрашивает себя до перехода SelfPoll bool // задача наблюдаема → страница сама опрашивает себя
PollEvery string
Error string Error string
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
Note string Note string
@@ -90,6 +90,15 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
} }
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil { if err != nil {
// Тик самообновления страницы идёт этим же маршрутом (hx-get="/download/{id}"
// с hx-select="#download-main"). Отвечать ему статусом ошибки нельзя: htmx не
// свопит 4xx/5xx и не снимает hx-trigger — страница осталась бы навсегда
// устаревшей, а опрос продолжался бы до закрытия вкладки. Навигационный GET
// (адресная строка, закладка) по-прежнему получает честный статус.
if isHTMX(r) {
s.fragTickErr(w, err, id, "download-main")
return
}
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
http.Error(w, "задача не найдена", http.StatusNotFound) http.Error(w, "задача не найдена", http.StatusNotFound)
return return
@@ -114,7 +123,10 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
Infohashes: d.HashList(), Infohashes: d.HashList(),
Context: d.Context, Context: d.Context,
State: string(d.State), State: string(d.State),
SelfPoll: d.State == store.StateCatched, SelfPoll: d.State.IsObservable(),
// Блока живых цифр качания на странице нет вовсе, а тик считает
// предпросмотр раскладки и ходит в ФС — интервал всегда медленный.
PollEvery: pollSlow,
Error: d.ErrorMsg.String, Error: d.ErrorMsg.String,
Note: desyncNote(d.State), Note: desyncNote(d.State),
CreatedAt: d.CreatedAt, CreatedAt: d.CreatedAt,
@@ -125,8 +137,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
d.State == store.StateTargetMissing, d.State == store.StateTargetMissing,
Retriable: d.State == store.StateFailed || d.State == store.StateStuck, Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Deletable: d.State == store.StateDone || d.State == store.StateOrphaned || Deletable: d.State.CanDelete(),
d.State == store.StateTargetMissing,
Dismissable: d.State.IsTerminal() && Dismissable: d.State.IsTerminal() &&
d.State != store.StateDeleted && d.State != store.StateCancelled, d.State != store.StateDeleted && d.State != store.StateCancelled,
} }
@@ -134,7 +145,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
// как в порядке и карточках списка); неразбираемое время просто опускаем. // как в порядке и карточках списка); неразбираемое время просто опускаем.
if t, ok := addedTime(d); ok { if t, ok := addedTime(d); ok {
view.Added = fmtDate(t, s.deps.Loc) view.Added = fmtDate(t, s.deps.Loc)
view.AddedAgo = humanizeAge(t, time.Now()) view.AddedAgo = humanizeAge(t, store.Now())
} }
if rd.Recognition != nil { if rd.Recognition != nil {
+77 -24
View File
@@ -18,6 +18,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"unicode/utf8" "unicode/utf8"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -52,6 +53,9 @@ type Reader interface {
// LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из // LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из
// загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке). // загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке).
LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error) LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error)
// ListDeletableDownloads — загрузки, разрешённые к полному удалению, без
// постраничной выдачи (страница группового удаления показывает их разом).
ListDeletableDownloads(ctx context.Context) ([]store.Download, error)
} }
// Deps — зависимости транспорта. // Deps — зависимости транспорта.
@@ -113,16 +117,22 @@ func NewRouter(d Deps) (http.Handler, error) {
// Веб-UI. // Веб-UI.
r.Get("/", s.handleIndex) r.Get("/", s.handleIndex)
r.Get("/download/{id}", s.handleDownload) r.Get("/download/{id}", s.handleDownload)
// Групповое удаление: выбор → подтверждение → исполнение. Отдельная
// страница, потому что живая перерисовка списка стёрла бы выбор человека.
r.Get("/delete", s.handleBulkDeletePage)
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера). // Партиалы телеметрии без потребителя в новой разметке: оставлены гасителями
// вкладок, отрисованных прошлой версией (см. handleFragProgress).
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress) r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding) r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
// Карточка целиком: самополлинг catched до перехода в downloading (бейдж, // Карточка целиком — тик самообновления списка: пока задача наблюдаема,
// имя и появившийся прогресс обновляются без перезагрузки). // карточка приносит текущее состояние без перезагрузки страницы.
r.Get("/fragments/downloads/{id}/card", s.handleFragCard) r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
// Тело ревью для поллинга recognizing (htmx-своп до готового плана). // Тело ревью для поллинга recognizing (htmx-своп до готового плана).
r.Get("/fragments/downloads/{id}/review", s.handleFragReview) r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
r.Post("/ui/downloads", s.handleUIAdd) r.Post("/ui/downloads", s.handleUIAdd)
r.Post("/ui/delete/confirm", s.handleBulkDeleteConfirm)
r.Post("/ui/delete", s.handleBulkDelete)
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel) r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry) r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
@@ -205,8 +215,9 @@ type downloadView struct {
State string State string
Error string Error string
Terminal bool Terminal bool
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг IsDownloading bool // активная загрузка → живой прогресс-бар
SelfPoll bool // catched → карточка сама опрашивает себя до перехода SelfPoll bool // задача наблюдаема → карточка сама опрашивает себя
PollEvery string // интервал самообновления карточки (pollFast/pollSlow)
Progress progressView // живой прогресс (заполняется в handleIndex из снимка) Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
Reviewable bool // review/deferred — есть экран ревью Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку Undoable bool // done — можно откатить раскладку
@@ -303,7 +314,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
layoutSizes = nil // деградируем: размер уедет в фолбэк «—», страница не падает layoutSizes = nil // деградируем: размер уедет в фолбэк «—», страница не падает
} }
now := time.Now() now := store.Now()
for _, d := range downloads { for _, d := range downloads {
view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID])) view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID]))
} }
@@ -311,10 +322,11 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
} }
// buildCardView собирает представление карточки списка из доменных данных и // buildCardView собирает представление карточки списка из доменных данных и
// живого снимка. Общий для полной страницы (handleIndex) и htmx-свопа карточки // живого снимка. Общий для полной страницы (handleIndex), тика самообновления
// после действия (renderCardFragment): чтобы htmx-ветка не дублировала обвязку // (handleFragCard) и htmx-свопа после действия (renderCardFragment): чтобы
// (рейтинг/размер/прогресс). Для retry→downloading карточка обязана нести // htmx-ветки не дублировали обвязку (рейтинг/размер/прогресс). Живые цифры едут
// прогресс-поллер — поэтому Progress заполняется здесь. // вместе с карточкой — своего опроса у блока прогресса нет, поэтому Progress
// заполняется здесь на каждом пути.
func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView { func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView {
v := s.toView(d, now) v := s.toView(d, now)
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг // Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
@@ -432,7 +444,9 @@ func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
res, err := s.deps.Ingestor.Ingest(r.Context(), req) res, err := s.deps.Ingestor.Ingest(r.Context(), req)
if err != nil { if err != nil {
redirectErr(w, r, userErr(r, err, res.DownloadID)) // Нулевой Result на любом пути ошибки — контракт ingest.Ingest;
// корреляционный ключ веб-формы, как и REST, — request_id.
redirectErr(w, r, userErr(r, err, ""))
return return
} }
if res.Deduplicated { if res.Deduplicated {
@@ -492,7 +506,7 @@ func (s *server) surfaceAction(w http.ResponseWriter, r *http.Request, id string
func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) { func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
d, err := s.deps.Reader.GetDownload(r.Context(), id) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragActionErr(w, err, id, "card-"+id)
return return
} }
sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id}) sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
@@ -500,7 +514,7 @@ func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id s
s.deps.Logger.Error("layout sizes", "download_id", id, "error", err) s.deps.Logger.Error("layout sizes", "download_id", id, "error", err)
sizes = nil // деградируем: размер уедет в фолбэк «—», фрагмент не падает sizes = nil // деградируем: размер уедет в фолбэк «—», фрагмент не падает
} }
v := s.buildCardView(*d, time.Now(), sizes[id]) v := s.buildCardView(*d, store.Now(), sizes[id])
if actionErr != nil { if actionErr != nil {
v.ActionError = userErr(r, actionErr, id) v.ActionError = userErr(r, actionErr, id)
} }
@@ -512,7 +526,7 @@ func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id s
func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) { func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragActionErr(w, err, id, "download-main")
return return
} }
v := s.buildDownloadView(id, rd) v := s.buildDownloadView(id, rd)
@@ -585,10 +599,10 @@ func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
} }
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context}) res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
if err != nil { if err != nil {
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit) — // Приём на любом пути ошибки возвращает нулевой Result (контракт
// тогда коррелируем по download_id, иначе (ранний разбор источника) по // ingest.Ingest) — идентификатора загрузки тут нет и быть не может,
// request_id. // коррелируем по request_id.
s.apiErr(w, r, err, res.DownloadID) s.apiErr(w, r, err, "")
return return
} }
status := http.StatusCreated status := http.StatusCreated
@@ -659,7 +673,8 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
Error: d.ErrorMsg.String, Error: d.ErrorMsg.String,
Terminal: d.State.IsTerminal(), Terminal: d.State.IsTerminal(),
IsDownloading: d.State == store.StateDownloading, IsDownloading: d.State == store.StateDownloading,
SelfPoll: d.State == store.StateCatched, SelfPoll: d.State.IsObservable(),
PollEvery: pollSlow,
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone, Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
@@ -667,6 +682,10 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
Retriable: d.State == store.StateFailed || d.State == store.StateStuck, Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Note: desyncNote(d.State), Note: desyncNote(d.State),
} }
// Быстрый интервал — только там, где на поверхности бегут цифры качания.
if v.IsDownloading {
v.PollEvery = pollFast
}
// Дата добавления в карточке — всегда (source_added_at → фолбэк created_at, // Дата добавления в карточке — всегда (source_added_at → фолбэк created_at,
// как в порядке списка); неразбираемое время просто опускаем. // как в порядке списка); неразбираемое время просто опускаем.
if t, ok := addedTime(d); ok { if t, ok := addedTime(d); ok {
@@ -682,12 +701,36 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
// несколько строк заголовка. // несколько строк заголовка.
func downloadTitle(d store.Download) string { func downloadTitle(d store.Download) string {
if d.DisplayName != "" { if d.DisplayName != "" {
return d.DisplayName return displaySafe(d.DisplayName)
} }
if d.RecTitle.Valid && d.RecTitle.String != "" { if d.RecTitle.Valid && d.RecTitle.String != "" {
return d.RecTitle.String return displaySafe(d.RecTitle.String)
} }
return shorten(oneLine(d.SourceRef), 80) return displaySafe(shorten(oneLine(d.SourceRef), 80))
}
// displaySafe готовит заголовок к показу: снимает управляющие символы
// направления письма (`unicode.Bidi_Control` — с ними строка читается не в том
// порядке, в каком хранится) и заменяет управляющие пробелом (перевод строки в
// заголовке склеил бы слова). Имя раздачи — недоверенный вход, а
// `html/template` экранирует разметку, но эти символы пропускает. Цена высока
// на экране подтверждения группового удаления: там поимённое чтение заголовков
// и есть предохранитель необратимой операции.
//
// Снимается ровно этот класс, не весь `unicode.Cf`: в `Cf` лежат и ZWJ/ZWNJ,
// без которых рассыпаются составные эмодзи и меняется написание персидских и
// индийских имён. Чистим на показе, а не на записи — хранение дословное, и
// поиск по списку идёт по сохранённому имени.
func displaySafe(s string) string {
return strings.Map(func(r rune) rune {
if unicode.Is(unicode.Bidi_Control, r) {
return -1
}
if r < 0x20 || r == 0x7f {
return ' '
}
return r
}, s)
} }
// oneLine схлопывает переводы строк и лишние пробелы — сырой источник в // oneLine схлопывает переводы строк и лишние пробелы — сырой источник в
@@ -768,7 +811,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и // (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и
// некорректный ввод команды (worker.ErrInvalidInput) → // некорректный ввод команды (worker.ErrInvalidInput) →
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели // 400; недокачанный источник (worker.ErrNotReady), коллизия цели
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее // (layout.ErrCollision), непомещающееся целевое имя (layout.ErrNameTooLong) и
// конфликт состояния (worker.ErrConflict) → 409; прочее
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только // → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
// сообщение + корреляционный ключ. // сообщение + корреляционный ключ.
func classifyErr(err error) (int, string) { func classifyErr(err error) (int, string) {
@@ -791,6 +835,10 @@ func classifyErr(err error) (int, string) {
// Целевой путь уже занят: задача штатно ушла в review с причиной — // Целевой путь уже занят: задача штатно ушла в review с причиной —
// это не сбой, а требующий разбора конфликт. // это не сбой, а требующий разбора конфликт.
return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью" return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью"
case errors.Is(err, layout.ErrNameTooLong):
// Целевое имя не помещается в файловую систему: задача штатно ушла в
// review, где название правится подсказкой. Не сбой сервера.
return http.StatusConflict, "целевое имя слишком длинное, задача отправлена в ревью"
case errors.Is(err, worker.ErrConflict): case errors.Is(err, worker.ErrConflict):
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой. // Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
return http.StatusConflict, "действие недоступно в текущем состоянии" return http.StatusConflict, "действие недоступно в текущем состоянии"
@@ -799,6 +847,11 @@ func classifyErr(err error) (int, string) {
return http.StatusBadRequest, errManualSource.Error() return http.StatusBadRequest, errManualSource.Error()
case errors.Is(err, errInvalidCandidate): case errors.Is(err, errInvalidCandidate):
return http.StatusBadRequest, errInvalidCandidate.Error() return http.StatusBadRequest, errInvalidCandidate.Error()
case errors.Is(err, errBatchEmpty), errors.Is(err, errBatchTooLarge),
errors.Is(err, errBatchBadID):
// Отказы разбора пачки группового удаления — промах ввода, не сбой:
// текст sentinel'а показывается человеку как есть.
return http.StatusBadRequest, err.Error()
default: default:
return http.StatusInternalServerError, "внутренняя ошибка" return http.StatusInternalServerError, "внутренняя ошибка"
} }
@@ -841,7 +894,7 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now() start := time.Now() //nolint:forbidigo // измеряем длительность запроса, а не метку времени в БД
next.ServeHTTP(ww, r) next.ServeHTTP(ww, r)
+26 -1
View File
@@ -94,11 +94,14 @@ func (f *fakeReader) GetDownload(_ context.Context, id string) (*store.Download,
func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[string]int64, error) { func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[string]int64, error) {
return nil, nil return nil, nil
} }
func (f *fakeReader) ListDeletableDownloads(_ context.Context) ([]store.Download, error) {
return nil, nil
}
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server { func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
t.Helper() t.Helper()
if d.Logger == nil { if d.Logger == nil {
d.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) d.Logger = slog.New(slog.DiscardHandler)
} }
h, err := httpapi.NewRouter(d) h, err := httpapi.NewRouter(d)
if err != nil { if err != nil {
@@ -1098,3 +1101,25 @@ func TestRerecognize(t *testing.T) {
t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid) t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid)
} }
} }
func TestAPICommandNameTooLong(t *testing.T) {
// Непомещающееся целевое имя (layout.ErrNameTooLong) → 409 (штатно ушло в
// review), не 500: это конфликт, требующий разбора, а не сбой сервера.
// Наружу — нейтральное сообщение, сырой текст ошибки остаётся в логах.
cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrNameTooLong)}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409", resp.StatusCode)
}
var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got)
if msg, _ := got["error"].(string); !strings.Contains(msg, "слишком длинное") {
t.Errorf("error = %q, want содержащее «слишком длинное»", msg)
}
}
+94 -26
View File
@@ -18,27 +18,43 @@ type LiveStatus interface {
Live(infohash string) (worker.Live, bool) Live(infohash string) (worker.Live, bool)
} }
// Интервалы самообновления поверхностей (значение hx-trigger="every …").
//
// - pollFast — поверхность с живыми цифрами качания (карточка в downloading).
// Равен [worker].poll_interval: воркер снимает телеметрию раз в 5 с, и
// опрашивать чаще значит возвращать тот же кадр (docs/database.md).
// - pollSlow — все прочие наблюдаемые поверхности, включая страницу
// /download/{id} в любом состоянии: там меняется только состояние, а сборка
// страницы считает предпросмотр раскладки и ходит в ФС.
const (
pollFast = "5s"
pollSlow = "15s"
)
// noLive — заглушка на случай, когда источник телеметрии не подключён // noLive — заглушка на случай, когда источник телеметрии не подключён
// (Deps.Live == nil): живых данных нет, UI деградирует штатно. // (Deps.Live == nil): живых данных нет, UI деградирует штатно.
type noLive struct{} type noLive struct{}
func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false } func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }
// progressView — живой прогресс активной загрузки (для карточки и фрагмента // progressView — живой прогресс активной загрузки (вложенный блок карточки).
// /progress). Active управляется store-состоянием (downloading), а не qbt: // Active управляется store-состоянием (downloading), а не qbt: вне downloading
// когда задача покидает downloading, фрагмент возвращается без поллинга. // скорость и ETA смысла не имеют, и блок не рисуется. Своего опроса блок не
// ведёт — цифры приезжают с тиком карточки (web-ui, «Самообновление живой
// задачи»).
type progressView struct { type progressView struct {
ID string ID string
Active bool // store-состояние downloading → показываем бар и поллим Active bool // store-состояние downloading → показываем бар
Has bool // есть данные снимка Has bool // есть данные снимка
Percent int Percent int
DlSpeed string DlSpeed string
ETA string ETA string
} }
// seedingView — живая статистика раздачи (для страницы и фрагмента /seeding). // seedingView — живая статистика раздачи (секция страницы загрузки).
// Has истинно только если торрент сидирует и данные есть — иначе секция // Has истинно только если торрент сидирует и данные есть — иначе секция
// деградирует (пустой контейнер, поллинг прекращается). // деградирует (пустой контейнер). Своего опроса секция не ведёт: она лежит
// внутри свопаемой области страницы, и её цифры приезжают с тиком страницы.
type seedingView struct { type seedingView struct {
ID string ID string
Has bool Has bool
@@ -79,7 +95,13 @@ func buildSeeding(id string, l worker.Live, ok bool) seedingView {
return v return v
} }
// handleFragProgress отдаёт партиал живого прогресса карточки (htmx-поллинг). // handleFragProgress отдаёт партиал живого прогресса карточки.
//
// Потребителя в новой разметке у маршрута нет: блок прогресса едет с тиком
// карточки. Маршрут оставлен гасителем вкладок, отрисованных прошлой версией:
// htmx не свопит 4xx/5xx и не снимает hx-trigger, поэтому удалённый маршрут
// заставил бы старую вкладку стучать бесконечно, а партиал без поллинга гасит
// её первым же тиком. Убирается отдельной уборкой после деплоя.
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) { func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
@@ -88,7 +110,7 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
} }
d, err := s.deps.Reader.GetDownload(r.Context(), id) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragTickErr(w, err, id, "dl-live-"+id)
return return
} }
active := d.State == store.StateDownloading active := d.State == store.StateDownloading
@@ -96,10 +118,11 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
s.render(w, "progress", buildProgress(id, active, l, ok)) s.render(w, "progress", buildProgress(id, active, l, ok))
} }
// handleFragCard отдаёт карточку списка целиком (htmx-самополлинг catched): // handleFragCard отдаёт карточку списка целиком — это тик её самообновления.
// пока загрузка в catched, карточка опрашивает себя и по переходе в downloading // Пока задача наблюдаема (State.IsObservable), карточка опрашивает себя и на
// приносит обновлённый бейдж/имя и прогресс-поллер; выйдя из catched, свежая // каждом тике приносит текущее состояние целиком: бейдж, заголовок, набор
// карточка уже не несёт самополлинга — цикл завершается сам. // действий и живые цифры. Перестала быть наблюдаемой — свежая карточка уже не
// несёт самополлинга, и цикл завершается сам.
func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) { func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
@@ -108,15 +131,25 @@ func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
} }
d, err := s.deps.Reader.GetDownload(r.Context(), id) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragTickErr(w, err, id, "card-"+id)
return return
} }
// layoutSize 0: у catched раскладки нет; в downloading размер берётся из // Размер читаем так же, как своповый путь действия: самообновление
// живого снимка внутри buildCardView. // обслуживает и состояния с разложенными файлами, и подмена известного
s.render(w, "card", s.buildCardView(*d, time.Now(), 0)) // размера прочерком была бы потерей поля полного рендера.
sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
if err != nil {
// WARN, а не ERROR: тик повторится сам (docs/conventions/logging.md).
s.deps.Logger.Warn("layout sizes", "download_id", id, "error", err)
sizes = nil // деградируем: размер уедет в фолбэк, тик не падает
}
s.render(w, "card", s.buildCardView(*d, store.Now(), sizes[id]))
} }
// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг). // handleFragSeeding отдаёт партиал секции «Раздача».
//
// Как и у прогресса, потребителя в новой разметке нет: секция едет с тиком
// страницы. Маршрут оставлен гасителем старых вкладок — см. handleFragProgress.
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) { func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
@@ -125,22 +158,57 @@ func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
} }
d, err := s.deps.Reader.GetDownload(r.Context(), id) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragTickErr(w, err, id, "seeding-"+id)
return return
} }
l, ok := s.liveFor(*d) l, ok := s.liveFor(*d)
s.render(w, "seeding", buildSeeding(id, l, ok)) s.render(w, "seeding", buildSeeding(id, l, ok))
} }
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound → // fragTickErr — отказ чтения на повторяющемся тике самообновления: 200 и
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе). // фрагмент, который объясняет положение дел и НЕ несёт самообновления.
func (s *server) fragErr(w http.ResponseWriter, err error, id string) { //
if errors.Is(err, store.ErrNotFound) { // Статусом ошибки отвечать нельзя: htmx не свопит DOM на 4xx/5xx, поэтому
http.Error(w, "не найдено", http.StatusNotFound) // поверхность осталась бы прежней навсегда (человек не отличит «ничего не
return // изменилось» от «сервер не отвечает»), а её опрос продолжался бы бесконечно —
} // при затяжном отказе хранилища это поток записей в журнал с каждой открытой
// вкладки. Фрагмент без hx-* завершает цикл сам (web-ui, «Самообновление живой
// задачи»).
//
// Уровень WARN, а не ERROR: у тика есть штатный ретрай — следующий тик повторит
// (docs/conventions/logging.md, «Ошибки»).
func (s *server) fragTickErr(w http.ResponseWriter, err error, id, rootID string) {
s.fragNote(w, err, id, rootID, false)
}
// fragActionErr — отказ чтения на разовом действии человека: тот же
// самозавершающийся фрагмент, но ERROR: ретрая у действия нет.
func (s *server) fragActionErr(w http.ResponseWriter, err error, id, rootID string) {
s.fragNote(w, err, id, rootID, true)
}
// fragNote отдаёт фрагмент отказа с корнем rootID. Корень обязателен и
// приходит от вызывающего: htmx свопит outerHTML, и фрагмент без целевого id
// снёс бы узел вместе с якорем — следующее действие и поллер цели не нашли бы
// (docs/conventions/web-ui.md, «Единый источник разметки»).
func (s *server) fragNote(w http.ResponseWriter, err error, id, rootID string, oneShot bool) {
text := "задача не найдена — обновите страницу"
if !errors.Is(err, store.ErrNotFound) {
if oneShot {
s.deps.Logger.Error("live fragment", "download_id", id, "error", err) s.deps.Logger.Error("live fragment", "download_id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError) } else {
s.deps.Logger.Warn("live fragment", "download_id", id, "error", err)
}
text = "не удалось обновить — обновите страницу"
}
s.render(w, "frag_note", fragNoteView{RootID: rootID, Text: text})
}
// fragNoteView — самозавершающийся фрагмент отказа (см. fragNote). RootID —
// id узла, который фрагмент собой заменяет.
type fragNoteView struct {
RootID string
Text string
} }
// --- форматирование телеметрии --- // --- форматирование телеметрии ---
+246 -17
View File
@@ -1,6 +1,7 @@
package httpapi package httpapi
import ( import (
"errors"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
@@ -9,8 +10,8 @@ import (
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
) )
// TestFragProgressDownloading: активная задача → фрагмент с прогрессом, // TestFragProgressDownloading: маршрут прогресса остался гасителем старых
// значениями снимка и атрибутами htmx-поллинга. // вкладок — отдаёт цифры снимка и НЕ несёт собственного опроса.
func TestFragProgressDownloading(t *testing.T) { func TestFragProgressDownloading(t *testing.T) {
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDownloading} dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDownloading}
lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}} lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
@@ -21,25 +22,80 @@ func TestFragProgressDownloading(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
body := rr.Body.String() body := rr.Body.String()
for _, want := range []string{`hx-trigger="every 3s"`, "/fragments/downloads/" + testULID + "/progress", "width:42%", "42%"} { for _, want := range []string{"width:42%", "42%"} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body) t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body)
} }
} }
if strings.Contains(body, "hx-trigger") {
t.Errorf("партиал прогресса всё ещё опрашивает сервер сам:\n%s", body)
}
} }
// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading, // TestProgressBlockHiddenOutsideDownloading: вне downloading блок живых цифр не
// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается). // рисуется вовсе — скорость и ETA там смысла не имеют. Проверка на отсутствие
func TestFragProgressStopsWhenNotDownloading(t *testing.T) { // hx-trigger сюда не годится: партиал не несёт его ни при каком входе, и такой
// тест был бы зелёным независимо от логики.
func TestProgressBlockHiddenOutsideDownloading(t *testing.T) {
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDone} dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDone}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{}) lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.9, DlSpeed: 6400000, ETA: 720}}}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
rr := get(t, h, "/fragments/downloads/"+testULID+"/progress") rr := get(t, h, "/fragments/downloads/"+testULID+"/progress")
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
if body := rr.Body.String(); strings.Contains(body, "hx-trigger") { body := rr.Body.String()
t.Errorf("завершённая задача всё ещё поллит:\n%s", body) for _, unwanted := range []string{`class="progress"`, "dl-stats", "90%"} {
if strings.Contains(body, unwanted) {
t.Errorf("вне downloading блок цифр не должен рисоваться, есть %q:\n%s", unwanted, body)
}
}
}
// TestFragErrKeepsSwapRoot: фрагмент отказа несёт корневой id того узла, который
// он собой заменяет. Иначе своп уносит якорь поверхности: экран ревью или
// страница загрузки теряют цель для всех своих действий и мертвы до перезагрузки
// (docs/conventions/web-ui.md, «Единый источник разметки»).
func TestFragErrKeepsSwapRoot(t *testing.T) {
cases := []struct{ path, root string }{
{"/fragments/downloads/" + testULID + "/card", `id="card-` + testULID + `"`},
{"/fragments/downloads/" + testULID + "/progress", `id="dl-live-` + testULID + `"`},
{"/fragments/downloads/" + testULID + "/seeding", `id="seeding-` + testULID + `"`},
{"/fragments/downloads/" + testULID + "/review", `id="review-main"`},
}
h := testRouterLive(t, stubReader{getErr: errors.New("db is gone")}, stubReviewer{}, stubLive{})
for _, c := range cases {
rr := get(t, h, c.path)
if rr.Code != http.StatusOK {
t.Errorf("%s: status = %d, want 200", c.path, rr.Code)
continue
}
if body := rr.Body.String(); !strings.Contains(body, c.root) {
t.Errorf("%s: фрагмент отказа без корня %s:\n%s", c.path, c.root, body)
}
}
}
// TestPageTickFailureSelfTerminates: тик страницы идёт тем же маршрутом, что и
// навигация, поэтому отказ на htmx-пути обязан отвечать 200 и фрагментом с
// корнем #download-main без hx-*; навигационный GET по-прежнему получает статус.
func TestPageTickFailureSelfTerminates(t *testing.T) {
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
rr := getHTMX(t, h, "/download/"+testULID)
if rr.Code != http.StatusOK {
t.Fatalf("тик страницы: status = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="download-main"`) {
t.Errorf("фрагмент отказа страницы без корня #download-main:\n%s", body)
}
if strings.Contains(body, "hx-trigger") {
t.Errorf("фрагмент отказа страницы не самозавершается:\n%s", body)
}
if rr := get(t, h, "/download/"+testULID); rr.Code != http.StatusNotFound {
t.Errorf("навигационный GET: status = %d, want 404", rr.Code)
} }
} }
@@ -57,11 +113,15 @@ func TestFragSeeding(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
body := rr.Body.String() body := rr.Body.String()
for _, want := range []string{"Раздача", "2.41", "38 / 14", `hx-trigger="every 3s"`} { for _, want := range []string{"Раздача", "2.41", "38 / 14"} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("фрагмент раздачи не содержит %q\n%s", want, body) t.Errorf("фрагмент раздачи не содержит %q\n%s", want, body)
} }
} }
// Секция лежит внутри свопаемой области страницы — своего опроса не ведёт.
if strings.Contains(body, "hx-trigger") {
t.Errorf("секция раздачи всё ещё опрашивает сервер сама:\n%s", body)
}
} }
// TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет. // TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет.
@@ -80,7 +140,8 @@ func TestFragSeedingDegrades(t *testing.T) {
} }
// TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже // TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже
// в первом кадре (значения снимка) и атрибуты поллинга. // в первом кадре (значения снимка), а опрашивает себя сама карточка — один
// поллер на поверхность, во вложенном блоке прогресса его нет.
func TestIndexCardShowsLiveProgress(t *testing.T) { func TestIndexCardShowsLiveProgress(t *testing.T) {
dl := store.Download{ID: testULID, SourceRef: "The.Bear.S03", Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih3", Kind: store.HashV1}}, State: store.StateDownloading} dl := store.Download{ID: testULID, SourceRef: "The.Bear.S03", Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih3", Kind: store.HashV1}}, State: store.StateDownloading}
lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}} lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}}
@@ -91,21 +152,189 @@ func TestIndexCardShowsLiveProgress(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
body := rr.Body.String() body := rr.Body.String()
for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/" + testULID + "/progress"} { for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/" + testULID + "/card"} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("карточка без живого прогресса: нет %q", want) t.Errorf("карточка без живого прогресса: нет %q", want)
} }
} }
if strings.Contains(body, "/fragments/downloads/"+testULID+"/progress") {
t.Errorf("вложенный блок прогресса опрашивает себя сам:\n%s", body)
}
if n := strings.Count(body, `hx-trigger="every`); n != 1 {
t.Errorf("объявлений самообновления на карточке = %d, want 1\n%s", n, body)
}
} }
// TestFragNotFound: фрагмент несуществующей задачи → 404. // TestFragTickOnMissingDownload: тик по исчезнувшей задаче отвечает 200 и
func TestFragNotFound(t *testing.T) { // фрагментом без hx-* — htmx не свопит 4xx/5xx, поэтому отказ статусом оставил
// бы карточку прежней навсегда, а опрос — бесконечным.
func TestFragTickOnMissingDownload(t *testing.T) {
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{}) h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
if rr := get(t, h, "/fragments/downloads/01arz3ndektsv4rrffq69g5fff/progress"); rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code) rr := get(t, h, "/fragments/downloads/01arz3ndektsv4rrffq69g5fff/card")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
} }
// Невалидный id → 404 без похода в БД. body := rr.Body.String()
if !strings.Contains(body, "не найдена") {
t.Errorf("фрагмент не объясняет отказ тика:\n%s", body)
}
if strings.Contains(body, "hx-trigger") || strings.Contains(body, "hx-get") {
t.Errorf("фрагмент отказа не самозавершается:\n%s", body)
}
}
// TestFragInvalidID: невалидный id → 404 без похода в БД (это не тик живой
// поверхности, а запрос по несуществующему адресу).
func TestFragInvalidID(t *testing.T) {
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound { if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound {
t.Fatalf("status(invalid id) = %d, want 404", rr.Code) t.Fatalf("status(invalid id) = %d, want 404", rr.Code)
} }
} }
// TestFragTickOnStoreFailure: отказ хранилища на тике — тоже 200 и
// самозавершающийся фрагмент, но с другим текстом: «не найдена» здесь соврало бы.
func TestFragTickOnStoreFailure(t *testing.T) {
h := testRouterLive(t, stubReader{getErr: errors.New("db is gone")}, stubReviewer{}, stubLive{})
rr := get(t, h, "/fragments/downloads/"+testULID+"/card")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "не удалось обновить") {
t.Errorf("отказ хранилища выдан за пропажу задачи:\n%s", body)
}
if strings.Contains(body, "hx-trigger") {
t.Errorf("фрагмент отказа не самозавершается:\n%s", body)
}
}
// TestFragCardSurvivesSizeFailure: отказ чтения размеров не роняет тик —
// карточка деградирует на прочерк, а не на пустой ответ.
func TestFragCardSurvivesSizeFailure(t *testing.T) {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateReview}
rd := stubReader{one: &dl, sizesErr: errors.New("db is busy")}
h := testRouterLive(t, rd, stubReviewer{}, stubLive{})
rr := get(t, h, "/fragments/downloads/"+testULID+"/card")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if body := rr.Body.String(); !strings.Contains(body, "Ревью →") {
t.Errorf("тик не пережил отказ чтения размеров:\n%s", body)
}
}
// TestCardSelfPollFollowsObservability: карточка опрашивает себя, пока задача
// наблюдаема, и замолкает, когда двигать её может только человек. failed,
// target_missing и orphaned наблюдаются: их возвращает в поток фоновая сверка.
func TestCardSelfPollFollowsObservability(t *testing.T) {
polling := []store.State{
store.StateCatched, store.StateDownloading, store.StateCompleted,
store.StateRecognizing, store.StateReview, store.StateLinking,
store.StateDeferred, store.StateStuck,
store.StateFailed, store.StateTargetMissing, store.StateOrphaned,
}
silent := []store.State{
store.StateDone, store.StateCancelled, store.StateReverted, store.StateDeleted,
}
for _, st := range polling {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: st}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if !strings.Contains(body, "/fragments/downloads/"+testULID+"/card") {
t.Errorf("%s: наблюдаемая карточка не опрашивает себя:\n%s", st, body)
}
}
for _, st := range silent {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: st}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if strings.Contains(body, "hx-trigger") {
t.Errorf("%s: ненаблюдаемая карточка продолжает опрос:\n%s", st, body)
}
}
}
// TestCardPollInterval: быстрый интервал — только там, где бегут цифры качания.
func TestCardPollInterval(t *testing.T) {
cases := []struct {
state store.State
want string
}{
{store.StateDownloading, `hx-trigger="every ` + pollFast + `"`},
{store.StateReview, `hx-trigger="every ` + pollSlow + `"`},
{store.StateCatched, `hx-trigger="every ` + pollSlow + `"`},
}
for _, c := range cases {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: c.state}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if !strings.Contains(body, c.want) {
t.Errorf("%s: нет %q\n%s", c.state, c.want, body)
}
}
}
// TestFragCardBringsNewStateAndActions: первый ответ фрагмента после смены
// состояния приносит новый бейдж и новый набор действий — ради этого change и
// затевался.
func TestFragCardBringsNewStateAndActions(t *testing.T) {
cases := []struct {
state store.State
want string
}{
{store.StateReview, "Ревью →"},
{store.StateDone, "Откатить"},
}
for _, c := range cases {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: c.state}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if !strings.Contains(body, c.want) {
t.Errorf("%s: фрагмент не принёс действие %q\n%s", c.state, c.want, body)
}
}
}
// TestDownloadPageSelfPoll: страница живёт по тому же правилу наблюдаемости,
// интервал у неё всегда медленный (блока живых цифр качания на ней нет), а
// секция «Раздача» своего опроса не ведёт — один поллер на поверхность.
func TestDownloadPageSelfPoll(t *testing.T) {
seedLive := stubLive{m: map[string]worker.Live{"ihp": {Seeding: true, Progress: 1, Ratio: 2.4, Seeds: 3, Peers: 1}}}
hashes := []store.Infohash{{DownloadID: testULID, Infohash: "ihp", Kind: store.HashV1}}
// Наблюдаемая задача с сидирующей раздачей: ровно одно объявление опроса.
dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateReview, Infohashes: hashes}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{data: &worker.ReviewData{Download: dl}}, seedLive)
body := get(t, h, "/download/"+testULID).Body.String()
if !strings.Contains(body, `hx-trigger="every `+pollSlow+`"`) {
t.Errorf("страница наблюдаемой задачи без медленного самообновления:\n%s", body)
}
if n := strings.Count(body, `hx-trigger="every`); n != 1 {
t.Errorf("объявлений самообновления на странице = %d, want 1", n)
}
// Ненаблюдаемая задача: страница замолкает.
done := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateDone, Infohashes: hashes}
h = testRouterLive(t, stubReader{one: &done}, stubReviewer{data: &worker.ReviewData{Download: done}}, seedLive)
if body := get(t, h, "/download/"+testULID).Body.String(); strings.Contains(body, `hx-trigger="every`) {
t.Errorf("страница ненаблюдаемой задачи продолжает опрос:\n%s", body)
}
}
// TestFragCardKeepsLayoutSize: самообновление не теряет полей полного рендера —
// размер разложенных файлов при отсутствии раздачи в снимке.
func TestFragCardKeepsLayoutSize(t *testing.T) {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateOrphaned}
rd := stubReader{one: &dl, sizes: map[string]int64{testULID: 3 << 30}}
h := testRouterLive(t, rd, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if !strings.Contains(body, "3.0 ГиБ") {
t.Errorf("фрагмент карточки потерял размер раскладки:\n%s", body)
}
}
+36 -3
View File
@@ -2,7 +2,6 @@ package httpapi
import ( import (
"context" "context"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -19,19 +18,43 @@ type stubReader struct {
list []store.Download list []store.Download
one *store.Download one *store.Download
sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк) sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк)
getErr error // отказ чтения задачи (не ErrNotFound)
sizesErr error // отказ чтения размеров раскладки
// Для страницы группового удаления: список разрешённых к удалению и
// поштучное чтение по id (страница подтверждения называет каждую поимённо).
deletable []store.Download
deletableErr error
byID map[string]store.Download
} }
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil } func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) { func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
return s.list, len(s.list), nil return s.list, len(s.list), nil
} }
func (s stubReader) GetDownload(context.Context, string) (*store.Download, error) { func (s stubReader) GetDownload(_ context.Context, id string) (*store.Download, error) {
if s.getErr != nil {
return nil, s.getErr
}
if s.byID != nil {
d, ok := s.byID[id]
if !ok {
return nil, store.ErrNotFound
}
return &d, nil
}
if s.one == nil { if s.one == nil {
return nil, store.ErrNotFound return nil, store.ErrNotFound
} }
return s.one, nil return s.one, nil
} }
func (s stubReader) ListDeletableDownloads(context.Context) ([]store.Download, error) {
return s.deletable, s.deletableErr
}
func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) { func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) {
if s.sizesErr != nil {
return nil, s.sizesErr
}
return s.sizes, nil return s.sizes, nil
} }
@@ -77,7 +100,7 @@ func testRouter(t *testing.T, r stubReader, rv stubReviewer) http.Handler {
func testRouterLive(t *testing.T, r stubReader, rv stubReviewer, lv stubLive) http.Handler { func testRouterLive(t *testing.T, r stubReader, rv stubReviewer, lv stubLive) http.Handler {
t.Helper() t.Helper()
h, err := NewRouter(Deps{ h, err := NewRouter(Deps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Logger: slog.New(slog.DiscardHandler),
Reader: r, Reader: r,
Reviewer: rv, Reviewer: rv,
Live: lv, Live: lv,
@@ -95,6 +118,16 @@ func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
return rr return rr
} }
// getHTMX — тот же GET, но помеченный как htmx-запрос (тик самообновления).
func getHTMX(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("HX-Request", "true")
h.ServeHTTP(rr, req)
return rr
}
// testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат). // testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат).
const testULID = "01arz3ndektsv4rrffq69g5fav" const testULID = "01arz3ndektsv4rrffq69g5fav"
+6 -1
View File
@@ -43,6 +43,7 @@ type reviewView struct {
State string State string
Error string // из ?err= Error string // из ?err=
StateError string // error_msg загрузки (напр. причина коллизии) StateError string // error_msg загрузки (напр. причина коллизии)
PreviewError string // почему предпросмотр не построился, посчитано на показе
MediaType string MediaType string
IsSeries bool IsSeries bool
Title string Title string
@@ -113,6 +114,10 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
State: string(rd.Download.State), State: string(rd.Download.State),
Error: errMsg, Error: errMsg,
StateError: rd.Download.ErrorMsg.String, StateError: rd.Download.ErrorMsg.String,
// Причина пустого предпросмотра считается на показе и потому всегда про
// текущий план; error_msg остался от последнего перехода и после смены
// источника уже не про него.
PreviewError: rd.PreviewError,
Hints: rd.Hints, Hints: rd.Hints,
} }
if rec := rd.Recognition; rec != nil { if rec := rd.Recognition; rec != nil {
@@ -450,7 +455,7 @@ func (s *server) handleFragReview(w http.ResponseWriter, r *http.Request) {
} }
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragTickErr(w, err, id, "review-main")
return return
} }
s.render(w, "review_main", buildReviewView(id, rd, "")) s.render(w, "review_main", buildReviewView(id, rd, ""))
+50
View File
@@ -2,6 +2,9 @@ package httpapi_test
import ( import (
"bytes" "bytes"
"encoding/json"
"errors"
"fmt"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
@@ -85,3 +88,50 @@ func TestUIAddUrlencoded(t *testing.T) {
t.Errorf("urlencoded source не проброшен: %q", ing.lastReq.Source) t.Errorf("urlencoded source не проброшен: %q", ing.lastReq.Source)
} }
} }
// Отказ приёма на HTTP-границе: идентификатора загрузки нет (контракт
// ingest.Ingest — нулевой Result на любом пути ошибки), поэтому корреляционным
// ключом остаётся request_id запроса. Проверяются оба HTTP-транспорта: REST
// отдаёт ключ полем тела, веб-форма — текстом флеш-сообщения в редиректе.
func TestIngestErrorCorrelatesByRequestID(t *testing.T) {
t.Run("REST", func(t *testing.T) {
ing := &fakeIngestor{err: fmt.Errorf("ingest: create download: %w", errors.New("boom"))}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
strings.NewReader(`{"source":"magnet:?xt=urn:btih:abc"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if s, _ := body["request_id"].(string); s == "" {
t.Errorf("в теле отказа нет request_id: %v", body)
}
if _, ok := body["download_id"]; ok {
t.Errorf("в теле отказа обещан download_id: %v", body)
}
})
t.Run("веб-форма", func(t *testing.T) {
ing := &fakeIngestor{err: fmt.Errorf("ingest: create download: %w", errors.New("boom"))}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads",
url.Values{"source": {"magnet:?xt=urn:btih:abc"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
loc := resp.Header.Get("Location")
if !strings.Contains(loc, "request_id%3D") && !strings.Contains(loc, "request_id=") {
t.Errorf("в сообщении отказа нет request_id: %q", loc)
}
if strings.Contains(loc, "download_id") {
t.Errorf("в сообщении отказа обещан download_id: %q", loc)
}
})
}
+25 -6
View File
@@ -66,7 +66,9 @@ type Request struct {
Context string // подсказка для распознавания (опц.) Context string // подсказка для распознавания (опц.)
} }
// Result — итог приёма. // Result — итог приёма. При ненулевой ошибке Ingest возвращает НУЛЕВОЙ Result:
// идентификатор загрузки, хеши, состояние и признак дедупликации не
// публикуются (см. Ingest).
type Result struct { type Result struct {
DownloadID string DownloadID string
Infohashes []string // все хеши источника (гибридный magnet: v1 и v2, v1 первым) Infohashes []string // все хеши источника (гибридный magnet: v1 и v2, v1 первым)
@@ -78,7 +80,24 @@ type Result struct {
// полей ссылки, дедуплицирует по активной задаче, иначе сохраняет загрузку в // полей ссылки, дедуплицирует по активной задаче, иначе сохраняет загрузку в
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод // `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
// имени выполняет worker (см. download-tracking). // имени выполняет worker (см. download-tracking).
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { //
// Контракт: на ЛЮБОМ пути ошибки возвращается нулевой Result. Приём не создаёт
// наблюдаемых последствий раньше, чем способен вернуть успех, а всё, что может
// отказать после заведения загрузки, делает worker. Транспорты на это
// опираются и не обещают идентификатора, которого нет: HTTP коррелирует отказ
// по request_id, Telegram — ключа не даёт (см. ingest-спеку, требование
// «Результат приёма при ошибке пуст»).
//
// Гарантия структурная — обнуление в одном defer, а не аккуратность каждой
// ветки возврата: перечень веток растёт, и именно расхождение перечня с
// комментариями транспортов породило исходный дефект.
func (s *Service) Ingest(ctx context.Context, req Request) (res Result, err error) {
defer func() {
if err != nil {
res = Result{}
}
}()
src, err := s.parse(req) src, err := s.parse(req)
if err != nil { if err != nil {
// Невалидный источник — норма (адресат не команда, а пользователь, и он // Невалидный источник — норма (адресат не команда, а пользователь, и он
@@ -179,10 +198,10 @@ func (s *Service) parse(req Request) (parsedSource, error) {
} }
// SourceRef — человекочитаемый референс (имя раздачи), НЕ адрес // SourceRef — человекочитаемый референс (имя раздачи), НЕ адрес
// добавления: torrent добавляется байтами (см. worker), не по SourceRef. // добавления: torrent добавляется байтами (см. worker), не по SourceRef.
// Фолбек на имя файла, если у раздачи нет содержательного имени // Фолбек на имя файла, если у раздачи нет содержательного имени:
// (пустое или NoName-сентинел "-"). // вырожденное значение отбросил разборщик, здесь остаётся пустота.
ref := strings.TrimSpace(info.DisplayName) ref := info.DisplayName
if ref == "" || ref == "-" { if ref == "" {
ref = strings.TrimSpace(req.TorrentName) ref = strings.TrimSpace(req.TorrentName)
} }
return parsedSource{ return parsedSource{
+39 -2
View File
@@ -3,8 +3,8 @@ package ingest
import ( import (
"context" "context"
"errors" "errors"
"io"
"log/slog" "log/slog"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -25,13 +25,22 @@ type fakeStore struct {
upgradeID string // downloadID последнего вызова UpgradeCatchedMagnetToTorrent upgradeID string // downloadID последнего вызова UpgradeCatchedMagnetToTorrent
upgradeBlob []byte // байты, переданные в апгрейд upgradeBlob []byte // байты, переданные в апгрейд
upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent
lookupErr error // отказ хранилища на дедуп-чеке
createErr error // отказ хранилища на заведении загрузки
} }
func (f *fakeStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) { func (f *fakeStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
if f.lookupErr != nil {
return nil, f.lookupErr
}
return f.active, nil return f.active, nil
} }
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) { func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
if f.createErr != nil {
return nil, f.createErr
}
if f.active != nil { if f.active != nil {
return f.active, nil return f.active, nil
} }
@@ -79,7 +88,7 @@ func (r *raceStore) UpgradeCatchedMagnetToTorrent(_ context.Context, _ string, _
} }
func newService(st Store) *Service { func newService(st Store) *Service {
return New(st, slog.New(slog.NewTextHandler(io.Discard, nil))) return New(st, slog.New(slog.DiscardHandler))
} }
// Быстрый приём: сохраняем загрузку в catched и сразу отвечаем; qBittorrent и // Быстрый приём: сохраняем загрузку в catched и сразу отвечаем; qBittorrent и
@@ -301,3 +310,31 @@ func TestIngestRejectsNonMagnet(t *testing.T) {
t.Error("не должно быть записи задачи") t.Error("не должно быть записи задачи")
} }
} }
// Контракт приёма: на ЛЮБОМ пути ошибки транспорту возвращается НУЛЕВОЙ Result.
// Транспорты на это опираются и не обещают идентификатора, которого нет
// (см. ingest-спеку, «Результат приёма при ошибке пуст»). Сравниваем результат
// с нулевым значением ЦЕЛИКОМ, а не по полю DownloadID: следующая ветвь отказа
// может заполнить другое поле.
func TestIngestReturnsZeroResultOnEveryErrorPath(t *testing.T) {
boom := errors.New("boom")
for _, tc := range []struct {
name string
fs *fakeStore
req Request
}{
{"невалидный источник", &fakeStore{}, Request{Source: "не magnet и не torrent"}},
{"сбой хранилища на дедуп-чеке", &fakeStore{lookupErr: boom}, Request{Source: sampleMagnet}},
{"сбой хранилища на заведении", &fakeStore{createErr: boom}, Request{Source: sampleMagnet}},
} {
t.Run(tc.name, func(t *testing.T) {
res, err := newService(tc.fs).Ingest(context.Background(), tc.req)
if err == nil {
t.Fatal("ожидалась ошибка")
}
if !reflect.DeepEqual(res, Result{}) {
t.Errorf("Result = %+v, want нулевой", res)
}
})
}
}
+15 -3
View File
@@ -134,10 +134,20 @@ func TestIngestTorrentTooLarge(t *testing.T) {
} }
} }
// У раздачи без имени source_ref берётся из имени файла (фолбек). // У раздачи без содержательного имени source_ref берётся из имени файла.
// Случая два, и они разные: раздача БЕЗ поля name (BestName() == "") и
// раздача, объявившая вырожденное `-` (metainfo.NoName) — второй нормализует
// разборщик, приём про него уже не знает.
func TestIngestTorrentNameFallback(t *testing.T) { func TestIngestTorrentNameFallback(t *testing.T) {
// Info без name → BestName() == "" → фолбек на TorrentName. for _, tc := range []struct {
info := metainfo.Info{Name: "", Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)} name string
infoName string
}{
{"без поля name", ""},
{"вырожденное имя", metainfo.NoName},
} {
t.Run(tc.name, func(t *testing.T) {
info := metainfo.Info{Name: tc.infoName, Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
infoBytes, err := bencode.Marshal(info) infoBytes, err := bencode.Marshal(info)
if err != nil { if err != nil {
t.Fatalf("marshal: %v", err) t.Fatalf("marshal: %v", err)
@@ -157,6 +167,8 @@ func TestIngestTorrentNameFallback(t *testing.T) {
if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" { if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" {
t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef) t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef)
} }
})
}
} }
func TestIngestTorrentInvalid(t *testing.T) { func TestIngestTorrentInvalid(t *testing.T) {
+1 -1
View File
@@ -81,7 +81,7 @@ func (c *Client) RefreshLibraries(ctx context.Context) error {
req.Header.Set("X-Emby-Token", c.apiKey) req.Header.Set("X-Emby-Token", c.apiKey)
log := logctx.FromOr(ctx, c.log) log := logctx.FromOr(ctx, c.log)
call := logging.ExtCall{Service: logging.ServiceJellyfin, Operation: "library/refresh", Start: time.Now()} call := logging.StartCall(logging.ServiceJellyfin, "library/refresh")
resp, err := c.hc.Do(req) resp, err := c.hc.Do(req)
if err != nil { if err != nil {
call.Failure(log, err) call.Failure(log, err)
+11 -1
View File
@@ -1,5 +1,5 @@
// Package layout раскладывает распознанные файлы по конвенциям Jellyfin // Package layout раскладывает распознанные файлы по конвенциям Jellyfin
// хардлинками, не трогая исходную раздачу (см. docs/specs/jellyfin-layout.md). // хардлинками, не трогая исходную раздачу (см. openspec/specs/file-layout/spec.md).
// //
// Инварианты безопасности (см. architecture.md → «Раскладка файлов»): // Инварианты безопасности (см. architecture.md → «Раскладка файлов»):
// - линкуем только файлы; целевые каталоги создаём mkdir; // - линкуем только файлы; целевые каталоги создаём mkdir;
@@ -171,6 +171,11 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
if !underRoot(root, dst) { if !underRoot(root, dst) {
return nil, fmt.Errorf("layout: target %q is outside library %q (file %q)", dst, root, f.Src) return nil, fmt.Errorf("layout: target %q is outside library %q (file %q)", dst, root, f.Src)
} }
// Длина проверяется ПОСЛЕ песочницы: путь, вышедший за библиотеку, —
// находка безопасности, и подменять её косметической причиной нельзя.
if err := checkComponentLengths(root, dst); err != nil {
return nil, err
}
links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind}) links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind})
} }
if len(links) == 0 { if len(links) == 0 {
@@ -267,6 +272,11 @@ type Result struct {
// ErrCollision — цель существует и это другой файл (нужен review). // ErrCollision — цель существует и это другой файл (нужен review).
var ErrCollision = errors.New("layout: target collision") var ErrCollision = errors.New("layout: target collision")
// ErrNameTooLong — компонент целевого пути длиннее предела длины имени
// (maxComponentBytes). Проверяется в BuildLinks, до первой операции с ФС:
// задача уходит в review с доменной причиной, а не в failed с текстом ядра.
var ErrNameTooLong = errors.New("layout: имя не помещается")
// ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных // ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных
// (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не // (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не
// единственный файл (см. state-reconciliation, инвариант безопасного Undo). // единственный файл (см. state-reconciliation, инвариант безопасного Undo).
+47
View File
@@ -6,6 +6,53 @@ import (
"strings" "strings"
) )
// maxComponentBytes — предел длины одного компонента целевого пути в БАЙТАХ
// UTF-8, а не в символах: ядро меряет NAME_MAX в байтах, и кириллическое
// название упирается в предел вдвое раньше латинского той же длины в знаках.
// Значение — NAME_MAX у ext4/xfs/btrfs; у ядра оно не выясняется, потому что
// раскладка обязана отказать до обращения к диску (см. spec file-layout).
// Цена обеих сторон: на ФС с меньшим пределом (часть зашифрованных) имя пройдёт
// проверку и упрётся в ядро — останется сегодняшний failed; на ФС с бо́льшим мы
// откажем строже, чем нужно. Лечение — правка этой константы, а не настройка:
// значение, которое некому выставить осознанно, не гибкость.
const maxComponentBytes = 255
// checkComponentLengths проверяет, что каждый компонент пути dst ПОД корнем
// root помещается в maxComponentBytes. Корень не проверяется: его каталоги задаёт
// оператор, и жаловаться на них раскладка не вправе. Возвращает ошибку,
// обёртывающую ErrNameTooLong и называющую непомещающийся компонент и его длину.
// Чистая функция: к диску не обращается.
func checkComponentLengths(root, dst string) error {
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(dst))
if err != nil {
return fmt.Errorf("layout: relative target %q: %w", dst, err)
}
for c := range strings.SplitSeq(rel, string(filepath.Separator)) {
if len(c) > maxComponentBytes {
return fmt.Errorf("%w: %q — %d байт при пределе %d",
ErrNameTooLong, shorten(c), len(c), maxComponentBytes)
}
}
return nil
}
// errNameSample — сколько рун непомещающегося имени показать в тексте ошибки.
// Текст уезжает в error_msg, а оттуда в баннер ревью и в карточку Telegram:
// имя целиком (а оно по условию длиннее 255 байт) заняло бы там весь экран.
// Точную длину несёт число рядом, поэтому образца хватает, чтобы узнать имя.
const errNameSample = 40
// shorten оставляет от имени начало и конец, выкидывая середину. Режет по рунам:
// обрыв посреди многобайтовой буквы дал бы в сообщении мусор.
func shorten(s string) string {
r := []rune(s)
if len(r) <= errNameSample {
return s
}
head := errNameSample / 2
return string(r[:head]) + "…" + string(r[len(r)-head:])
}
// sanitizeComponent чистит один компонент пути (имя папки/файла): убирает // sanitizeComponent чистит один компонент пути (имя папки/файла): убирает
// разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает // разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает
// пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри // пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри
+250
View File
@@ -0,0 +1,250 @@
package layout
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// countDirEntries считает всё, что появилось под корнем библиотеки: проверка
// длины обязана отказать ДО первой операции с ФС, поэтому пусто — это часть
// утверждения, а не гигиена.
func countDirEntries(t *testing.T, root string) int {
t.Helper()
n := 0
err := filepath.WalkDir(root, func(p string, _ os.DirEntry, err error) error {
if err != nil {
return err
}
if p != root {
n++
}
return nil
})
if err != nil {
t.Fatal(err)
}
return n
}
// 2.1 Имя файла длиннее предела: отказ целиком, ни одного каталога на диске.
func TestBuildLinks_FileNameTooLong(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
// Название впритык под папку, но имя файла = база + ".mkv".
title := strings.Repeat("a", maxComponentBytes-len(" (1999)"))
plan := Plan{
Type: Movie, Title: title, Year: 1999,
Files: []PlanFile{{Src: src, Role: RoleMain}},
}
links, err := f.l.BuildLinks(plan)
if !errors.Is(err, ErrNameTooLong) {
t.Fatalf("err = %v, want ErrNameTooLong", err)
}
if links != nil {
t.Errorf("links = %v, want nil (отказ целиком)", links)
}
if n := countDirEntries(t, f.movies); n != 0 {
t.Errorf("под movies появилось %d записей, ожидалось 0 (проверка до операций с ФС)", n)
}
}
// 2.2 Папка тайтла длиннее предела, хотя имя файла бы поместилось.
func TestBuildLinks_FolderNameTooLong(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
// База помещается, но provider-тег выталкивает папку за предел; имя файла
// тега не несёт и остаётся коротким.
tag := "tmdbid-693134"
title := strings.Repeat("b", maxComponentBytes-len(" (1999)")-len(" [")-len(tag)-len("]"))
plan := Plan{
Type: Movie, Title: title, Year: 1999, ProviderTag: tag,
Files: []PlanFile{{Src: src, Role: RoleMain}},
}
if _, err := f.l.BuildLinks(plan); err != nil {
t.Fatalf("контроль: имя ровно в предел должно проходить, got %v", err)
}
plan.Title = title + "c" // +1 байт — папка перестаёт помещаться
_, err := f.l.BuildLinks(plan)
if !errors.Is(err, ErrNameTooLong) {
t.Fatalf("err = %v, want ErrNameTooLong", err)
}
if n := countDirEntries(t, f.movies); n != 0 {
t.Errorf("под movies появилось %d записей, ожидалось 0", n)
}
}
// 2.3 Предел меряется в БАЙТАХ, а не в рунах: кириллица упирается вдвое раньше.
// Заодно граница 255/256.
func TestBuildLinks_LimitIsBytesNotRunes(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
build := func(title string) error {
_, err := f.l.BuildLinks(Plan{
Type: Movie, Title: title,
Files: []PlanFile{{Src: src, Role: RoleMain}},
})
return err
}
const ext = ".mkv"
// Граница ровно на 255 байтах имени файла.
fit := strings.Repeat("a", maxComponentBytes-len(ext))
if err := build(fit); err != nil {
t.Fatalf("255 байт должны помещаться, got %v", err)
}
if err := build(fit + "a"); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("256 байт: err = %v, want ErrNameTooLong", err)
}
// Столько же ЗНАКОВ кириллицей — вдвое больше байтов, отказ.
cyr := strings.Repeat("я", maxComponentBytes-len(ext))
if err := build(cyr); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("кириллица той же длины в знаках: err = %v, want ErrNameTooLong "+
"(предел меряется в байтах)", err)
}
// Граница кириллицей — тоже по байтам. 125 букв = 250 байт, плюс ".mkv" = 254:
// помещается. Ещё одна буква даёт 256 — не помещается.
cyrFit := strings.Repeat("я", (maxComponentBytes-len(ext))/2)
if err := build(cyrFit); err != nil {
t.Fatalf("254 байта кириллицей должны помещаться, got %v", err)
}
if err := build(cyrFit + "я"); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("256 байт кириллицей: err = %v, want ErrNameTooLong", err)
}
}
// 2.4 Приоритет: путь и вне библиотеки, и слишком длинный → отказ называет
// выход за библиотеку, а не длину. Иначе находка безопасности спрячется за
// косметической причиной.
func TestBuildLinks_OutsideLibraryBeatsTooLong(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
// Санитизация режет разделители, поэтому traversal через Title недостижим;
// проверяем сам порядок на checkComponentLengths напрямую: путь вне корня
// до неё не доходит, а внутри корня — доходит.
outside := filepath.Join(filepath.Dir(f.movies), strings.Repeat("z", 300))
if underRoot(f.movies, outside) {
t.Fatal("подготовка теста неверна: путь обязан быть вне корня")
}
_, err := f.l.BuildLinks(Plan{
Type: Movie, Title: strings.Repeat("z", 300),
Files: []PlanFile{{Src: src, Role: RoleMain}},
})
if !errors.Is(err, ErrNameTooLong) {
t.Fatalf("внутри корня длинное имя даёт ErrNameTooLong, got %v", err)
}
// Прямая сверка порядка в BuildLinks: underRoot стоит раньше и его отказ
// формулируется своим текстом (см. layout.go).
if err := checkComponentLengths(f.movies, outside); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("checkComponentLengths вне корня: %v", err)
}
}
// 2.7 Название не усекается: на непомещающемся входе ссылок нет вовсе, а не
// возвращена усечённая. Усечение схлопнуло бы два разных названия в один каталог.
func TestBuildLinks_NoTruncation(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
links, err := f.l.BuildLinks(Plan{
Type: Movie, Title: strings.Repeat("d", 400),
Files: []PlanFile{{Src: src, Role: RoleMain}},
})
if err == nil {
t.Fatalf("ожидался отказ, получено %d ссылок", len(links))
}
if len(links) != 0 {
t.Errorf("вернулось %d ссылок — усечение недопустимо", len(links))
}
}
// 2.8 Мерится финальный компонент, а не название: суффикс субтитров
// дописывается после и обязан учитываться.
func TestBuildLinks_SubtitleSuffixCounted(t *testing.T) {
f := newFixture(t)
video := f.srcFile(t, "long/ep.mkv", "x")
sub := f.srcFile(t, "long/ep.ru.srt", "y")
// База подобрана так, что видеофайл помещается, а субтитр с ".ru.forced.srt" —
// уже нет: разница ровно в длине суффикса.
const stem = " S01E02"
title := strings.Repeat("e", maxComponentBytes-len(stem)-len(".mkv"))
plan := Plan{
Type: Series, Title: title,
Files: []PlanFile{
{Src: video, Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
},
}
if _, err := f.l.BuildLinks(plan); err != nil {
t.Fatalf("видеофайл впритык должен проходить, got %v", err)
}
plan.Files = append(plan.Files, PlanFile{
Src: sub, Role: RoleSubtitle, Season: intp(1), Episode: intp(2),
Lang: "ru", Flags: []string{"forced"},
})
if _, err := f.l.BuildLinks(plan); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("субтитр с суффиксом обязан упереться: err = %v", err)
}
}
// 2.9 Вырожденные входы проверку не роняют.
func TestCheckComponentLengths_Degenerate(t *testing.T) {
root := "/srv/media/movies"
cases := []string{
root + "/",
root + "/ ",
root + "/" + string([]byte{0xff, 0xfe, 0xfd}), // невалидный UTF-8
root + "/" + strings.Repeat("x", 1<<16),
root,
}
for _, dst := range cases {
// Требование одно: не паниковать и вернуть значение.
_ = checkComponentLengths(root, dst)
}
if err := checkComponentLengths(root, root+"/"+strings.Repeat("x", 1<<16)); !errors.Is(err, ErrNameTooLong) {
t.Error("очень длинный компонент обязан давать ErrNameTooLong")
}
}
// Корень библиотеки под проверку не попадает: его каталоги задаёт оператор.
func TestCheckComponentLengths_RootNotChecked(t *testing.T) {
root := "/srv/" + strings.Repeat("r", 300)
if err := checkComponentLengths(root, root+"/Dune (2024)/Dune (2024).mkv"); err != nil {
t.Errorf("длинный корень не должен считаться отказом: %v", err)
}
}
// Нерасчислимый относительный путь (корень абсолютный, цель относительная) —
// не отказ по длине, а отдельная ошибка: путать их нельзя, иначе диагноз соврёт.
func TestCheckComponentLengths_UnrelatablePath(t *testing.T) {
err := checkComponentLengths("/srv/media/movies", "relative/path.mkv")
if err == nil {
t.Fatal("want error for unrelatable path")
}
if errors.Is(err, ErrNameTooLong) {
t.Errorf("нерасчислимый путь не должен выдаваться за отказ по длине: %v", err)
}
}
// shorten держит текст причины коротким: имя в сообщении усечено серединой, а
// точную длину несёт число рядом. Короткое имя не трогается.
func TestShorten(t *testing.T) {
short := strings.Repeat("a", errNameSample)
if got := shorten(short); got != short {
t.Errorf("имя в предел образца не должно меняться: %q", got)
}
long := strings.Repeat("я", 200)
got := shorten(long)
if r := []rune(got); len(r) != errNameSample+1 { // +1 — многоточие
t.Errorf("длина образца = %d рун, want %d", len(r), errNameSample+1)
}
if !strings.Contains(got, "…") {
t.Errorf("усечённое имя должно нести многоточие: %q", got)
}
// Режем по рунам: обрыв посреди буквы дал бы мусор вместо кириллицы.
if strings.ContainsRune(got, '') {
t.Errorf("усечение разорвало руну: %q", got)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Package llm — провайдер LLM за интерфейсом (дискриминатор type). // Package llm — провайдер LLM за интерфейсом (дискриминатор type).
// //
// Реализация выбирается полем [llm].type (см. docs/specs/recognition.md). // Реализация выбирается полем [llm].type (см. openspec/specs/recognition/spec.md).
// Первый и пока единственный тип — "openai-compat": OpenAI-совместимый Chat // Первый и пока единственный тип — "openai-compat": OpenAI-совместимый Chat
// Completions API (локальные серверы LM Studio/llama.cpp/Ollama и облачные // Completions API (локальные серверы LM Studio/llama.cpp/Ollama и облачные
// совместимые провайдеры — DeepSeek, Qwen и др.). // совместимые провайдеры — DeepSeek, Qwen и др.).

Some files were not shown because too many files have changed in this diff Show More