Compare commits

..
108 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
avandClaude Opus 4.8 1d6f8f3449 review: убрать явный переключатель типа movie↔series из всех транспортов
Кнопка «Тип» была только в Telegram (доменная SetType = жёсткий override
media_type + хинт + перераспознавание), в вебе её нет. Это создавало
расхождение поверхностей и внутреннюю противоречивость спеки review.
Решение: смена типа — редкий случай, для него достаточно «Уточнить»
(перераспознавание с явным указанием типа). Явный переключатель не нужен
ни на одной поверхности.

Снято: Telegram-кнопка и callback type:, worker.SetType, ставший мёртвым
override-плумбинг media_type (ovrMediaType, ветка applyOverrides, хелпер
oppositeType) и стейл-хвосты в тестах httpapi. Спека review — три MODIFIED
требования (запрет на все поверхности, «фиксация типа» убрана из команд и
из быстрых действий Telegram, иллюстрация override заменена на закрепление
источника). Синхронизирован docs/specs/review-ux.md.

Change заархивирован: openspec/changes/archive/2026-07-18-review-remove-type-switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:50:43 +03:00
avandClaude Opus 4.8 023fdacda5 tgbot: тесты на экранирование пути/причин и формат download_id в opErr
Финальная сверка спек notifications отметила два сценария спеки без
прицельного теста (спецсимволы в пути раскладки/причинах, формат id в
opErr) — поведение верное, но регресс не ловился. Закрываю тестами.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:09:48 +03:00
avandClaude Opus 4.8 f75d6b1f91 tgbot: выбор кандидата метабазы inline-кнопками в карточке ревью
Когда для распознавания сохранены кандидаты метабазы, карточка подтверждения
бота показывает кнопку «🗂 База (N)». По ней двухшагово (как delete/dismiss)
разворачивается список кандидатов inline-кнопками; выбор пиннит источник через
worker.ChooseCandidate (ручной матч, без авто-раскладки) и обновляет карточку.
Веб остаётся точкой точных правок (ручной ввод id/URL, «без базы»).

Безопасность границы: id кандидата из callback_data валидируется как ULID
(ident.Parse) до доменного вызова, как в вебе. Текст inline-кнопок Telegram не
парсится как HTML — название кандидата в подписи не экранируется.

SDD: change telegram-vybor-nahodok — дельта notifications (ADDED «Выбор
кандидата метабазы из карточки подтверждения бота») + review (MODIFIED
«Разделение труда транспортов»: быстрый выбор кандидата — Telegram-действие).
Влито в specs, change заархивирован. Миграций БД нет (кандидаты уже в БД).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:02:47 +03:00
avandClaude Opus 4.8 10a6348d39 tgbot: показывать запись матча метабазы (провайдер+id+ссылка)
В карточке подтверждения и уведомлении о готовности бот теперь показывает
запись матча метабазы — провайдер, id и кликабельную ссылку на страницу
записи, — как веб-страница /download/{id} и экран ревью. Так ошибочную
привязку видно и из Telegram.

Билдер URL записи (providerURL/matchURL) вынесен из internal/httpapi в ядро
internal/worker (worker.ProviderURL + метод (*ReviewData).MatchURL()), чтобы
оба транспорта строили ссылку одинаково; httpapi делегирует туда. baseLine
переведён на эффективные provider/id (с учётом ручных правок), URL в href
экранируется escHref (сверх esc закрывает кавычку — иначе изготовленный id
разорвал бы атрибут и Telegram отклонил бы сообщение). При отсутствии матча
карточка ревью показывает «нет матча», уведомление о готовности строку
опускает.

Capability notifications: ADDED «Показ записи матча метабазы» + MODIFIED
требования об экранировании (id матча и URL, контекст href).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 15:41:28 +03:00
avandClaude Opus 4.8 4a58b0bda0 tgbot: download id моноширинным (code) для tap-to-copy
Включён HTML parse mode у всех исходящих сообщений бота (send + edit-путь
refreshCard), download id выводится моноширинным <code> — в клиентах Telegram
по нему работает tap-to-copy (скопировать id для /download/{id} или диагностики).
Префикс # / download_id= остаётся вне <code>, чтобы копировался чистый id.

Parse mode делает разметку значимой для всех текстов, поэтому добавлен
escape-хелпер и экранированы все внешние/недоверенные фрагменты: display name,
распознанное название, источник/контекст, целевой путь, причины, provider,
error_code/error_msg (инвариант «выход LLM недоверенный»). esc применяется
последним шагом, после усечения, чтобы обрез не разрубил HTML-сущность.

Capability notifications: два ADDED-требования (формат id + экранирование).
Беклог: задача закрыта, зонтичный telegram-revyu-uvedomleniy обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 15:17:26 +03:00
av 85e27b28e2 Добавил задачу про расхождение нумерации в Бибопе 2026-07-18 14:53:12 +03:00
avandClaude Opus 4.8 236d886f9e Скиллы: батч-оркестратор задач + ветконезависимый пайплайн
Новый скилл task-batch: проводит несколько задач беклога разом — план
порядка/зависимостей, каждая задача сабагентом в своём worktree через
task-pipeline, интеграция в master по одной ветке rebase/ff (линейная
история), финальные тесты + сверка кода с требованиями по затронутым
capability. Пред-назначение номеров миграций, потолок параллелизма 2-3,
политика частичного провала (вливаем только зелёные), оговорки про
семантический конфликт одной capability и изоляцию тестов.

task-pipeline: коммит в текущую ветку вместо master (работает и вручную,
и под оркестратором в worktree); поведенческая верификация через skill
verify для нетривиальных задач.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 09:06:39 +03:00
avandClaude Opus 4.8 50f29b56aa Беклог + чистка: две находки аудита в беклог, поправлен устаревший комментарий
Аудит capability после пачки lifecycle-задач вскрыл две пред-существующие
находки (вне scope самих задач) — заведены в беклог:
- catched-source-type-namer-okno (средний): addReq не пересобирается из свежего
  source_type в окне namer'а; самоисцеляется через magnet_timeout→Retry.
- dismiss-cancel-user-dismiss-marker (низкий): веб-UI зовёт Cancel вместо Dismiss
  на не-терминальных, теряется маркер user_dismiss.

Инлайн: finishRecognition — комментарий врал про «Ф3, авто-раскладки нет»;
фактически авто-раскладка идёт при Decision.Auto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 08:44:36 +03:00
av 659fa5ec44 Слияние: уборка торрента при cancel во время add (T4)
# Conflicts:
#	docs/backlog/README.md
2026-07-17 22:11:53 +03:00
avandClaude Opus 4.8 3a00fde058 Жизненный цикл: уборка торрента при отмене во время добавления (F3/NIT-13)
Отмена задачи (catched→cancelled) в окно, пока worker вне блокировки выводит
имя (LLM) и делает qbt.Add, оставляла добавленный торрент в qBittorrent без
задачи-владельца: PromoteCatched корректно пропускал переход, но источник уже
качался/сидировал вечно, а усыновить его назад нельзя (хеши принадлежат
отменённой задаче). Спека покрывала переход состояния, но не побочный эффект.

Комбинированная защита в processCatched:
- re-read состояния под w.mu прямо перед qbt.Add — при отмене источник не
  добавляется вовсе (сужает окно гонки);
- свежий листинг перед add подтверждает отсутствие infohash — признак «своего»
  торрента; при сбое листинга/присутствии add не делаем (усыновит следующий тик);
- при отмене в окне после add (промах PromoteCatched, подтверждённый re-read'ом
  state != catched) — уборка добавленного нами торрента qbt.Delete(_, true);
- WARN/ERROR-логи по этому пути с корреляцией по download_id, без секретов.

Гарантия «удаляем только своё»: удаление-с-данными достижимо ТОЛЬКО после
подтверждённого отсутствия infohash перед add, поэтому пред-существующий/чужой
торрент с тем же хешем никогда не сносится (негативный инвариант). Обоснование
по инварианту «источник неприкосновенен» — в design.md изменения.

Дельта — download-tracking (требование «Добавление пойманной загрузки в
qBittorrent»): re-read перед add, подтверждение отсутствия, уборка при отмене,
негативный сценарий. Тесты покрывают все ветки (skip-before-add, cleanup после
add, пред-существующий не удаляется, сбой БД не удаляет, сбой листинга не
добавляет).

Change archived: 2026-07-17-cancel-during-add-cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:10:21 +03:00
av d02d88f49a Слияние: Defer отклоняет catched, снято мёртвое ребро (T3) 2026-07-17 21:39:41 +03:00
avandClaude Opus 4.8 098695011f Жизненный цикл: Defer запрещён из пре-источникового catched (MAJOR-6)
Команда Defer гардила только IsTerminal() и потому принимала catched
(торрент ещё не добавлен в qBittorrent). Defer из catched уводил задачу
в лимбо → необратимый deleted: processCatched листает только catched и
больше её не подхватывал, а последующие команды через отсутствие
источника выводили deleted (ноль исходящих рёбер), хотя байты .torrent
лежат в download_torrent.

- Worker.Defer отклоняет catched с ErrConflict (транслируется в 409 /
  редирект с сообщением); прочие не-терминальные состояния, где раздача
  уже есть, принимает как раньше.
- Снято мёртвое ребро графа catched → deferred (allowedTransitions);
  инвариант «deferred из каждого не-терминального» уточнён: кроме
  пре-источникового catched. catched — единственное состояние без
  раздачи среди не-терминальных.
- Тесты: Defer из catched отклоняется и не меняет состояние; инвариант
  графа обновлён + негативная проверка ребра.
- OpenSpec: MODIFIED «Команды ревью и их эффекты» (review) с позитивным
  и негативным сценариями; change заархивирован, дельта влита в спеку.
- Беклог: закрыта review-major6-defer-catched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:39:01 +03:00
av 91de53b8f1 Слияние: режиссёр в карточке загрузки (T2)
# Conflicts:
#	docs/backlog/README.md
2026-07-17 21:22:35 +03:00
av 0ced0e18a5 Слияние: Jellyfin-рескан после reverted/deleted (T1) 2026-07-17 21:22:13 +03:00
avandClaude Opus 4.8 5948f4d219 Веб-UI: режиссёр в блоке «Распознано как» на странице загрузки
На /download/{id} поле «Режиссёр» было захардкожено прочерком, хотя экран
ревью режиссёра уже выводит: слоистое разрешение полей ярлыка было заперто в
неэкспортируемом worker.effectiveDisplayName. Из-за этого билдеры вью видели
только слой распознавания+матч (rd.Plan.Director) без слоя контекста — то же
на экране ревью.

Вынес разрешение в экспортируемую naming.EffectiveFields(parsedContext, plan)
LabelFields с методом Label(): выбор слоя по сырым значениям (как прежде),
выбранные скаляры возвращаются очищенными (sanitize идемпотентен, display_name
побайтно тот же). effectiveDisplayName стал тонкой обёрткой; страница загрузки
и экран ревью берут режиссёра из той же функции — согласованно с заголовком.

OpenSpec: web-ui (ADDED «Режиссёр в блоке распознавания страницы загрузки»),
review (MODIFIED «Инфо и предпросмотр выбранного источника» — слоистое
разрешение с фолбэком на контекст). Change заархивирован. Беклог: закрыта
rezhisser-v-kartochke-zagruzki.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:21:27 +03:00
avandClaude Opus 4.8 1639ebfdd7 Пересканирование Jellyfin: расширить триггер на reverted и deleted
Скан Jellyfin (POST /Library/Refresh) слался только при входе в done.
После Undo (reverted) и Delete (deleted) наши хардлинки сняты, а Jellyfin
держал битые записи до скана по расписанию.

Гейт скана в едином чекпоинте transitionErr переведён с state == done на
предикат triggersScan(state) по множеству {done, reverted, deleted}: гейт по
состоянию-цели естественно ловит пользовательские Undo/Delete и
reconcile-производный deleted, идемпотентно. target_missing/orphaned —
промежуточный рассинхрон (ждём relink/лечения) — исключены.

OpenSpec: заведена и влита дельта file-layout (требование
«Пересканирование Jellyfin после изменения библиотечных ссылок»); change
архивирован. Синк рукописных доков architecture.md/workflow.md. Тесты:
скан стреляет на reverted и deleted, молчит на входе вне множества.
Закрыта задача беклога jellyfin-skan-posle-udaleniya.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:14:18 +03:00
avandClaude Opus 4.8 0354a8c96b Беклог: чистка четырёх закрытых задач (mobilnaya-kv, btn-ghost, F7–F10, lifecycle-minor)
Реализованы и влиты; MINOR-9 (I/O под глобальным w.mu) осознанно waived
для one-user home-сервера.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:37:58 +03:00
avandClaude Opus 4.8 06a0e1ce41 OpenSpec: архив change retry-reject-broken-torrent (синк state-reconciliation)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:36:04 +03:00
avandClaude Opus 4.8 bcc7b2d76b Жизненный цикл: claim-токен распознавания, индексация хешей, retry сломанного торрента
Три мелких фикса из docs/backlog/review-lifecycle-minor.md (ревью Fable
2026-07-08). MINOR-9 (I/O под глобальным w.mu) осознанно waive для
one-user home-сервера — не трогаем.

MINOR-8: claim-токен распознавания. recognizeOne фиксирует updated_at на
момент claim (перечитывая запись после перехода в recognizing), а
finishRecognition коммитит результат, только если токен совпал. Иначе за
время LLM-вызова задачу увели из recognizing и вернули обратно
(cancel → relink revive) — это уже другой эпизод, устаревший результат
отбрасываем, задача остаётся в recognizing для перезапуска поллингом.

NIT-11: lookup-мапы (byHash/live/torrentByInfohash) больше не индексируют
усечённый 40-hex t.Hash v2-only торрентов. Новый хелпер torrentIndexHashes
зеркалит выбор torrentHashes: t.Hash берём только при отсутствии обоих
infohash_v1/v2. Убирает теоретический ложный матч по коллизии длины.

NIT-12: retry живого, но сломанного торрента (error/missingFiles) теперь
отклоняется с подсказкой починить раздачу (recheck) в qBittorrent, вместо
бессмысленной переотдачи источника (сверка тут же вернула бы задачу в
failed). Повторный Add — только когда раздачи в qBittorrent нет. Меняет
спеку state-reconciliation → дельта openspec/changes/2026-07-17-retry-reject-broken-torrent
(не архивировал).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:35:14 +03:00
avandClaude Opus 4.8 b8017d65eb Приём/UI: пачка фиксов границ и парсинга (F7–F10, N2)
Пять независимых bugfix'ов из ревью приёма (docs/backlog/review-f7-f10-ingest-ui-fixes.md):

- F7: oversized .torrent через веб отдавал 500. Введён sentinel
  ingest.ErrTorrentTooLarge, classifyErr транслирует его в 400.
- F8: гонка fast-path attach с cancel. Пред-рид
  FindReingestBlockingByInfohash больше не короткозамыкает активную
  запись — авторитетное дедуп-решение принимает CreateDownloadIfNoActive
  под BEGIN IMMEDIATE; короткозамыкание оставлено только для терминальных
  desync-записей (target_missing/orphaned). F6-апгрейд сохранён.
- F9: magnet — регистронезависимый URN-префикс xt (RFC 2141);
  tgbot.ParseMessage срезает хвостовую пунктуацию, приклеенную жадным
  matchем.
- F10: cap контекста до 16 KiB в ingest.Ingest (единственное место
  слияния — покрывает все транспорты), рунобезопасная обрезка + маркер.
- N2: httpapi.shorten режет по рунам, не байтам — кириллица не рвётся в
  U+FFFD.

Добавлены юнит-тесты на каждое исправленное поведение.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:35:14 +03:00
avandClaude Opus 4.8 17d9f23630 Веб-UI: единый вес кнопок, убрана невидимая btn-ghost из применений
Класс .btn-ghost прозрачен (нет аффорданса без hover, на тач невидим), плюс
веса действий рассогласованы между списком (card.html) и страницей
(download_main.html). Назначаем семантику весов явно:

- «Откатить» = btn-danger везде (в списке был btn-ghost); в список добавлен
  префикс ↩, чтобы список и страница совпадали.
- «Отменить/Отклонить» = btn-danger везде (на странице был btn-ghost).
- «Обновить имя» = нейтральный .btn (был btn-ghost).

Определение .btn-ghost в jellybit.css оставлено на случай легитимного
использования — правились только применения. Косметический nit-фикс, htmx-пути
и деградация без JS не затронуты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:35:14 +03:00
avandClaude Opus 4.8 bd7bf60b33 Веб-UI: перенос длинных значений .kv (фикс горизонтального скролла на мобильном)
Блок «Информация о торренте» на /download/{id} на узком экране выталкивал
страницу за вьюпорт: grid-blowout на `.kv` (grid-template-columns:auto 1fr)
из-за неразрывного 40-символьного infohash — дефолтный min-width:auto грид-
элемента не давал колонке 1fr сжаться уже min-content хэша.

Точечный фикс (вариант «а»): `.kv dd{overflow-wrap:anywhere;min-width:0}` +
`.kv dd.mono{word-break:break-all}`. Правка штатно чинит и потенциально
длинный RecTitle в блоке «Распознано как» (тот же `.kv`). На обычной ширине
вёрстка не меняется.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:35:14 +03:00
avandClaude Opus 4.8 3e470e3ae6 Беклог: перенос четырёх задач из инбокса Tududi
Инбокс Tududi разгружен: четыре открытые задачи проекта оформлены файлами
в docs/backlog и помечены выполненными в Tududi.

- режиссёр в блоке «Распознано как» на странице загрузки (средний);
- карточка информации о торренте вылезает за экран на мобильном (средний);
- сигнал Jellyfin после отката и удаления файлов (средний);
- кнопки выглядят как кнопки: btn-ghost и рассогласование весов (низкий).

Каждая задача проверена по коду: все четыре актуальны, ни одну не закрыли
прошлые коммиты. В телах — точки правки, развилки и нужные дельты спек.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:02:22 +03:00
avandClaude Opus 4.8 02d4ecc2aa display_name: слоистое разрешение полей + сохранение режиссёра из контекста
Единый источник полей отображаемого имени и один рендер полного ярлыка на
всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный
«Название (режиссёр, год). Сезон N» но выбрасывал структуру, а перелив по
распознаванию — усечённый «Title (Year)».

- Слоистое разрешение скаляров имени: override → recognition(+match) →
  новый базовый слой «контекст» (download.parsed_context, JSON naming.Fields).
- naming: публичные Fields/Label/Derive, вынесен единый рендер; удалён
  FormatTitleYear. Сводка сезонов вынесена в recognize.SeasonSummary.
- Режиссёр из метабазы (решение A2): TMDB/TVDB credits через опциональный
  metadata.DirectorProvider; авто-матч кладёт в plan.Director, ручной выбор
  кандидата тянет credits и пиннит ovrDirector. Метабаза бьёт контекст.
- refreshDisplayNameLocked строит полный ярлык из эффективных полей;
  инфо-панель ревью показывает загруженного режиссёра.
- Миграция 0011_parsed_context + ER-схема. Всё косметика: на пути/раскладку
  не влияет, приём/вывод имени не валятся (best-effort).

Закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат ярлыка».
OpenSpec: archive/2026-07-11-field-resolution-display-name (ingest,
recognition, metadata-match, review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:50:08 +03:00
avandClaude Opus 4.8 9472bfdd83 Спеки+беклог: закрытие батча UI-задач (favicon, cancelled, значок типа, ellipsis, oob-панель)
Реализованное поведение перенесено в openspec/specs:
- web-ui: cancelled скрыт по умолчанию наравне с deleted; значок типа в карточке;
- review: панель действий обновляется oob-фрагментом при свопе источника.
Пять закрытых задач удалены из docs/backlog + индекса.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:46:28 +03:00
avandClaude Opus 4.8 3f80d36727 Веб-UI: правило .type-ico для значка типа в карточке (ревью-nit)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:35:32 +03:00
av 49747e1281 Слияние WT-3: oob-обновление панели действий ревью 2026-07-11 09:32:53 +03:00
av 3465c1ea62 Слияние WT-2: список загрузок и форма приёма (cancelled hide, значок типа, ellipsis) 2026-07-11 09:32:52 +03:00
avandClaude Opus 4.8 44c5952e49 Список: скрытие cancelled, значок типа, ellipsis имени .torrent
Три мелкие правки веб-UI списка загрузок:

- Скрываем cancelled в общем списке наравне с deleted: под группой all
  без IncludeDeleted теперь `state NOT IN (deleted, cancelled)`. reverted
  остаётся видимым. Тумблер и комментарии-инварианты приведены в
  соответствие («включая отменённые и удалённые»).
- Значок типа (🎬 фильм / 📺 сериал) в строке списка: media_type текущей
  попытки распознавания протянут через LEFT JOIN в ListDownloadsPage
  (Download.RecMediaType, симметрично RecTitle) и downloadView.MediaType;
  значок рендерится в card.html, при нераспознанном типе значка нет.
- Ellipsis для длинного имени .torrent-файла: у .btn-file max-width +
  overflow/ellipsis на лейбле, чтобы длинное имя не распирало .add-row;
  показываем базовое имя без .torrent, полное — в title (JS-энхансмент).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:31:27 +03:00
avandClaude Opus 4.8 847d471852 Ревью: oob-обновление панели действий при свопе источника
Кнопка «Применить» (завязана на HasLinks) лежит вне #source-block и не
обновлялась при htmx-свопе выбора источника — в краевом случае (пустой
предпросмотр из-за коллизии) рассинхронивалась с превью до полной
перезагрузки. Выделил панель в партиал review_action_bar с id=action-bar;
reviewBlockAction теперь рендерит review_source_swap — свежий #source-block
плюс oob-копию панели (hx-swap-oob), так кнопка синхронно отражает HasLinks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:28:45 +03:00
avandClaude Opus 4.8 2545d64e8b Веб-UI: фавиконка (SVG) для вкладки/PWA
SVG-иконка (jelly-фиолетовый бейдж с play-треугольником и «битами»),
подключена во все три страничных шаблона через asset-хелпер; статика
уже раздаётся и embed берёт web/static целиком.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:25:50 +03:00
avandClaude Opus 4.8 c4c0c6bb3f Беклог: груминг трёх задач из Tududi (значок типа, download id code, ревью уведомлений)
Перенос сырых задач проекта jellybit из Tududi в беклог с контекстом и
кросс-ссылками:
- значок типа (фильм/сериал) в списке загрузок;
- download id моноширинным (code) в Telegram — упирается в parse mode + escape;
- зонтичный аудит уведомлений бота.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:15:56 +03:00
avandClaude Opus 4.8 e8ce828296 Беклог: полный формат ярлыка «Обновить имя» + скрытие cancelled в списке
- Кнопка «Обновить имя» должна давать формат add-шага (Название (режиссёр,
  год), сезон; всё опц. кроме названия) вместо усечённого «Title (Year)».
- Отменённые (cancelled) скрывать в общем списке наравне с удалёнными;
  тумблер «показать удалённые» раскрывает и cancelled, и deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:25:11 +03:00
avandClaude Opus 4.8 1369a9cabe Приём: дедуп по target_missing/orphaned + стоп-кран «Закрыть»
Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в target_missing: дедуп искал только активную задачу,
а target_missing терминален → заводилась новая загрузка, воркер усыновлял
уже присутствующий торрент и раскладывал его.

- Приём: критерий дедупа расширен до «блокирующей повторный приём» =
  активные ∪ {target_missing, orphaned}. Повторный приём такого инфохэша
  привязывается к существующей записи (спящей, без обращения к qBittorrent),
  а не плодит близнеца. Прочие терминальные (done/cancelled/failed/reverted/
  deleted) повторный приём не блокируют — осознанная свежая попытка. Новый
  read-метод FindReingestBlockingByInfohash (приоритет активной над desync);
  общий active-гард не тронут.
- Команда «Закрыть» (Dismiss) — универсальный стоп-кран из любого состояния,
  кроме deleted → cancelled (error_code=user_dismiss). Только меняет статус:
  файлы (в т.ч. хардлинки done/orphaned) и раздачу qBittorrent не трогает,
  в отличие от «Удалить». Веб — danger-зона внизу страницы; Telegram —
  кнопка с подтверждением; из cancelled — идемпотентный no-op.
- Транспорты при дедупе на desync-запись сообщают адресно (target_missing —
  привязать заново/закрыть; orphaned — закрыть и добавить заново); веб при
  дедупе ведёт на страницу существующей записи.

Спеки: ingest (дедуп), state-reconciliation (стоп-кран); граф переходов
допополнен рёбрами <терминал>→cancelled. OpenSpec change
dedup-target-missing-and-dismiss заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:15:37 +03:00
avandClaude Opus 4.8 b8657120fe Приём: усыновление присутствующего в qBittorrent торрента вместо дубль-Add (409)
processCatched перед Add проверяет присутствие торрента в qBittorrent (один
листинг на тик): если раздача уже есть — усыновляем (promote catched→downloading
без повторного Add и без LLM-namer, имя из раздачи), иначе добавляем как раньше.
Это убирает бесконечный цикл дубль-Add → 409 → ретрай и лишние вызовы LLM.
Инвариант приёма «одна активная на infohash» делает различие «наш/чужой»
ненужным. source_type перечитывается под замком (сужение гонки апгрейда F6);
при недоступности qBittorrent тик пропускается без вызова LLM.

Дедуп на приёме (дубль на уже активную задачу) теперь отражается явным ответом
бота «дубль уже активной #id — добавление отменено».

Спека download-tracking обновлена (OpenSpec change заархивирован); закрыта
задача беклога review-f2-promote-without-add.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:36:47 +03:00
avandClaude Opus 4.8 0c9421f4c1 Имя: восстановление display_name после распознавания + гард пустого входа
Голый magnet без dn/контекста заставлял namer звать LLM на пустом входе,
и модель галлюцинировала мусорное имя («Unknown»), которое писалось и в
display_name, и в rename qBittorrent, а заодно ломало UI-фолбэк на
распознанное название. Верное каноническое имя, вычисляемое позже при
распознавании, никуда не переливалось.

- naming: гард пустого входа в DeriveName (нет контекста и подсказки → ""
  без вызова LLM) + детерминированный форматтер FormatTitleYear.
- qbt: операция RenameTorrent (переименование существующей раздачи).
- store: SetDisplayName — обновление имени постфактум без гарда состояния.
- worker: refreshDisplayNameLocked/RefreshDisplayName — перелив канонического
  имени (эффективный план) в display_name + best-effort rename раздачи по
  реальному t.Hash; авто-триггер при подтверждении матча (choose/manual add).
- web-ui: кнопка «Обновить имя» на странице загрузки (htmx-своп заголовка,
  деградация без JS), видимая при наличии распознавания (вкл. done/orphaned).

Спека: дельты ingest/review/web-ui влиты в openspec/specs; change
refresh-display-name заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:51:04 +03:00
avandClaude Opus 4.8 e2ea1840c9 Распознавание: санитайзинг названий от LLM + безгодовой фолбэк сверки
Кейс «Harold and the Purple Crayon»: LLM отдал title с кириллической
буквой-двойником, сырое название ушло в запрос TVDB дословно (не нашлось),
а гейт нормализации кир/лат двойники не сворачивал — двойной промах, пустой
список кандидатов, ручной ввод id.

- recognition: санитайзинг человекочитаемых полей плана (title/original_title/
  provider_hint) на границе разбора, до валидации: strip control/zero-width,
  collapse пробелов, потокенная свёртка homoglyph-двойников по курируемой
  кир↔лат таблице. files[].src не трогаем (обязаны биться с торрентом).
- metadata-match: тот же fold в normalize (гейт) как defense-in-depth;
  безгодовой второй проход сверки как fallback при известном годе и промахе
  первого — восстанавливает off-by-one авто-матчи и пополняет кандидатов
  review. В fallback требуем известный год кандидата (год-unknown → review,
  не авто); гейт год ±1 и инвариант авто-матча не двигаются.

Спеки recognition/metadata-match обновлены, change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:45:19 +03:00
avandClaude Opus 4.8 7d8a455e47 Логирование: классификация доменных ошибок (500→409/400) + конвенции
Штатные конфликты и промахи ввода возвращались голым fmt.Errorf, поэтому
classifyErr отправлял их в 500 «внутренняя ошибка» вместо 409/400 (и logCmd
писал ERROR вместо DEBUG). Продолжение f8fb4fa (Tier A), по итогам ревью Fable.

Классификация ошибок:
- новый sentinel worker.ErrInvalidInput → 400 для валидации ввода команд
  (refine/set type/ignore/add source/set provider/choose candidate);
- обёртки %w ErrConflict в Cancel/Retry/Defer/Undo (штатный конфликт состояния);
- classifyErr: ErrInvalidInput→400, layout.ErrCollision→409 (коллизия цели
  штатно уводит в review); ветка ErrCollision в tgbot (сообщение + refreshCard);
- logCmd относит ErrInvalidInput и ErrCollision в DEBUG «command rejected».

Конвенции (docs/conventions):
- logging.md: публичные команды воркера = доменная граница (лог один раз,
  logCmd); таблица уровней доменных отказов (граница команды vs асинхронная
  стадия); правило про *url.Error/секреты в URL; канон категории
  state transition; уровень повторяющихся сбоев фоновых циклов;
- errors.md: таблица маппинга ошибка→статус; развилка «транзиентный ответ vs
  персистентная диагностика» решена как (а) — error_msg/reasons на review-экране
  и tg-карточке = операторская поверхность владельца (сырой текст ок, секреты
  запрещены; аудит подтвердил, что секреты туда не текут).

Унификация категории лога state transition: cancel/retry/relink/recovery
переведены с семантических msg на общий state transition (from/to) — весь
жизненный цикл собирается одним jq-фильтром.

Мелочи: reason-коды linkPlan в const-блок; httpapi лог-поля id→download_id и
msg «… failed»; комментарий «почему» у parseIgnored; preview build failure в
ReviewData DEBUG→WARN.

Беклог: задача сведена к остатку (ext.* ERROR-шторм при недоступном qBittorrent
+ эскалация устойчивого сбоя тика), понижена в приоритете.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:57:12 +03:00
avandClaude Opus 4.8 864c44aebd Беклог: классификация доменных ошибок (500→409/400) и конвенции логирования
Отложенные Tier B/C по итогам ревью Fable (продолжение f8fb4fa): sentinel-
обёртки ErrConflict/валидации, classifyErr для layout.ErrCollision, единая
категория переходов, правила уровней и *url.Error/секретов в docs/conventions,
решение по сырому err.Error() в reasons/error_msg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:32:09 +03:00
avandClaude Opus 4.8 f8fb4fabb3 Логирование: доменная граница ошибок + защита секретов в логах
Приём торрента через Telegram молча падал без записи в логах. Разобрали
цепочку и починили логирование/обработку ошибок по конвенции logging.md
(логирует граница домена один раз, транспорты — нет).

Доменная граница логирует исход:
- ingest.Ingest: сбой БД → ERROR, невалидный источник → DEBUG;
- команды воркера (Apply/Cancel/Retry/Refine/…) — единый чокпоинт logCmd
  (ERROR для инфраструктурного сбоя; DEBUG для conflict/not-ready/not-found),
  закрывает и Telegram-, и HTTP-путь; дублирующие ERROR-логи в tgbot сняты;
- внутренний логгер tgbotapi заведён в slog: сбои long-poll getUpdates
  больше не уходят в stdlib log мимо структурированных логов;
- тихое закрытие канала обновлений бота → ERROR.

Защита секретов (инвариант «секреты не в логи»):
- общий logging.SanitizeErr убирает URL из *url.Error;
- закрыты утечки токена бота (getMe на старте, getFile, Send/Request)
  и api_key TMDB (query-параметр, попадавший в *url.Error на ERROR);
- покрыто тестом internal/logging/sanitize_test.go.

Ревью двумя сабагентами (fable): инфраструктурные и доменные ошибки.
Отложено (не в scope этого коммита): обёртка ErrConflict в
Cancel/Defer/Retry и классификация 500→409/400, обновление docs/conventions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:29:38 +03:00
avandClaude Opus 4.8 5c3ef79496 Раскладка: сходимость папки сериала (второй сезон в ту же папку)
При подтверждённом матче база папки (имя+год) наследуется от живой
папки-якоря того же (provider, provider_id) вместо печати заново из выхода
LLM — так второй/последующий сезон ложится в ТУ ЖЕ папку, а не заводит
рядом почти одинаковую. Отдельная сущность «тайтл» не вводится.

- layout: Plan.FolderBase перекрывает базу в папке и именах файлов;
  TitleFolder разбирает dst_path в папку тайтла и базу (снятие тега).
- store: LiveTitleFolders — dst_path живых ссылок того же матча.
- worker: resolveFolderBase (живость якоря — по наличию папки на диске,
  os.Lstat, а не по статусу ссылки в БД) в linkPlan и в превью ревью
  (превью=применение); рассинхрон нескольких живых папок → review из
  linking (deferred→review в графе нет).

Схема БД не менялась. Change series-folder-convergence заархивирован,
требования влиты в openspec/specs/file-layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:50:49 +03:00
avandClaude Opus 4.8 3f1a928000 Единое окно: полное пользовательское удаление загрузки (delete)
Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные
хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит
раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted.
Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне;
инициатор различается через error_code=user_delete. Подтверждение обязательно:
веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый
inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои
ссылки под movies/series). Граф переходов не менялся — рёбра уже были.

OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:17:51 +03:00
avandClaude Opus 4.8 e68f96df9e Тулинг: openspec 1.4.1 → 1.5.0, регенерация инструкций opsx
Обновлён npm-пакет @fission-ai/openspec до 1.5.0; `openspec update`
перегенерировал команды opsx:* и скиллы openspec-* под новую версию
(добавлены пояснения про --store для multi-repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:39:04 +03:00
avandClaude Opus 4.8 93a1ba8e7e Тулинг: скилл task-pipeline + два ревьювера качества
Скилл .claude/skills/task-pipeline оркеструет задачу по SDD от беклога до
коммита (opsx explore→propose→ревью спек→apply→ревью кода→archive→чистка
беклога), автономно, с выходом на пользователя только на развилках.

Кастомные ревьюверы .claude/agents: jellybit-review-specs (оптика спек) и
jellybit-review-code (архитектура/инварианты/конвенции/стиль). Подключены как
чекпоинты скилла: на тривиальной задаче — один review-code, на нетривиальной —
оба параллельно.

Частично закрывает беклог-задачу agenty-revyuvery-kachestva: остался ревьювер
наименований (ждёт словарь единого языка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:37:00 +03:00
avandClaude Opus 4.8 2bd2a97bfc Беклог: длинное имя .torrent-файла ломает верстку add-row
Выбор .torrent с длинным именем распирает лейбл файл-пикера (нет ограничения
ширины у .btn-file), поле source сжимается, «Добавить» уезжает. Косметика:
ellipsis на лейбле; фикс-хуки на CSS/шаблон в теле задачи.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:36:14 +03:00
avandClaude Opus 4.8 2a5a65f2d5 Приём: пропажа источника у активной загрузки → failed(source_gone) (MAJOR-3)
Раздача активной (downloading) загрузки, исчезнувшая из qBittorrent (удалил
пользователь/другой клиент), делала задачу вечным зомби: поллинг промахивался
по torrentFor, писал Warn и continue каждый тик — состояние не менялось,
уведомления и телеметрии не было, checkTimeouts без торрента не срабатывал.
Пропажей источника у downloading не владел никто (сверка рассинхрона покрывает
только done/target_missing/orphaned, восстановление — failed/stuck).

Активный цикл Poll теперь применяет тот же дебаунс пропажи источника, что и
сверка рассинхрона (source_miss_count / source_missing_threshold): после порога
подряд идущих промахов задача уходит downloading → failed с distinct error_code
source_gone и уведомлением. До порога транзиентная недоступность qBit
(рестарт демона) задачу не роняет. source_gone восстановлению сверкой не
подлежит (удаление намеренно), но штатно retriable — Retry заново отдаёт
сохранённый источник; Retry сбрасывает source_miss_count, чтобы вернувшаяся
задача получила полное грейс-окно, а не упала снова на ближайшем тике.

Ребро downloading → failed уже было в графе, миграций/полей БД нет. Спека
download-tracking дополнена требованием, диаграмма workflow.md — ребром.
Change downloading-source-gone заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:05:04 +03:00
avandClaude Opus 4.8 bfd469bd43 Беклог: закрыты F1, F6, MAJOR-1/2, MAJOR-4, MINOR-7
Реализованы и прошли ревью параллельной волной (worktree):
- F1 (гард дедуп-дозаписи) + F6 (апгрейд catched-magnet→torrent)
- MAJOR-1/2 (сброс базиса ретрая + простой от last_activity) + клэмп
  last_activity из будущего
- MAJOR-4 (sweep linking + persist→review) + MINOR-7 (transitionErr)

Суть переехала в openspec/specs (ingest, state-reconciliation,
file-layout) и в код. В F2 добавлен указатель на смежное окно
F6↔воркер, найденное этим ревью.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:37:44 +03:00
avandClaude Opus 4.8 78c61605fd Retry/stall: игнорировать last_activity из будущего (клэмп)
Наблюдение из ревью кластера B: если qBittorrent отдаёт last_activity
впереди now (перекос часов или sentinel «никогда не был активен»),
stallDuration уходил в минус и реально застрявший торрент никогда не
помечался stuck. Теперь значение из будущего трактуется как непригодное
и простой считается от базиса добавления (addedBasis), как при
отсутствующем last_activity. Поведение спеки не меняется — оборонительная
деталь реализации.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:35:33 +03:00
avandClaude Opus 4.8 4cc4de4269 OpenSpec: архивация трёх параллельных changes + синк спек
Итог параллельной волны фиксов (worktree-изоляция, cherry-pick в master):
- ingest-dedup-integrity (F1, F6) → спека ingest
- retry-stall-basis (MAJOR-1, MAJOR-2) → спека state-reconciliation
- linking-transition-robustness (MAJOR-4, MINOR-7) → спеки file-layout
  и state-reconciliation

Дельты влиты в openspec/specs, changes перенесены в
openspec/changes/archive/2026-07-08-*. Беклог не трогаю (по решению).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:21:22 +03:00
avandClaude Opus 4.8 9bab7dc402 Устойчивость раскладки и переходов linking (MAJOR-4, MINOR-7)
Закрывает две связанные дыры «claim-then-side-effect» в раскладке хардлинками.

MINOR-7: transition глотал ошибку записи состояния — на путях Apply и
авто-раскладки выполнение продолжалось к хардлинкам при незакоммиченном claim
перехода в linking, а финальный linking→done отклонялся графом (задача застревала
со stale-планом). Выделен transitionErr, возвращающий ошибку; Apply и
finishRecognition прерываются ДО linkPlan при провале claim. Обёртка transition
(void) сохранена для fire-and-forget переходов — соседние функции воркера не
тронуты.

MAJOR-4: (A) провал CreateFileLinks после создания хардлинков больше не оставляет
задачу в linking голым return — уводим в review (код persist), повтор Apply
идемпотентен. (B) новый шаг pollOnce sweepLinking возвращает осиротевшие после
краха linking-задачи в review (код interrupted) на тике и старте; любая linking
под w.mu устарела по построению. Восстановлен инвариант «у каждого нетерминального
состояния есть владелец».

Граф переходов не тронут (ребро linking→review уже объявлено). Тесты: провал claim
не создаёт хардлинков; провал учёта уводит в review; sweep осиротевшего linking.

OpenSpec-change linking-transition-robustness (дельты file-layout,
state-reconciliation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:18:28 +03:00
avandClaude Opus 4.8 8261d5b55d Retry/stall: сброс базиса таймаута + простой от last_activity (MAJOR-1, MAJOR-2)
Два связанных бага семантики таймаутов зависания и ручного retry.

MAJOR-1: Retry живого торрента не сбрасывал базис отсчёта таймаута — задача
мгновенно снова падала в stuck на ближайшем тике. Вводим колонку
download.retried_at (миграция 0010): ручной retry фиксирует момент и
приподнимает пол обоих таймаутов (max(базис, retried_at)). Хранится в БД, а
не в памяти, чтобы сброс пережил тик поллинга и рестарт.

MAJOR-2: stuck_after мерил ВОЗРАСТ торрента (от added_on), а не ПРОСТОЙ —
долго качавшийся торрент, на миг зашедший в stalledDL, ложно уходил в stuck
со «stalled for 5h». Теперь stuck_after мерит простой от qBit last_activity
(новое поле qbt.Torrent из того же ответа /torrents/info); magnet_timeout
по-прежнему мерит возраст (семантически верно). checkTimeouts разбит на
torrentAge/stallDuration/addedBasis/retriedFloor.

NIT-10: фолбэк базиса возраста added_on→created_at сохранён и покрыт.
NIT-12: retry перестаёт перецепляться к сломанному живому торренту
(error/missingFiles) — повторно отдаёт источник (перецепка к нему
бессмысленна: reconcile тут же вернул бы в failed).

Спека: дельта state-reconciliation (MODIFIED «Восстановление зависшей
загрузки» и «Ручной повтор»), правка docs/specs/workflow.md (устранено
противоречие «возраст vs простой»), ER-схема database.md.

Тесты: TestRetryResetsTimeoutBasis (следующий тик после retry — прячется в
TestRetryReattachesNoReadd), TestStallMeasuredFromLastActivity,
TestSetRetriedAtOverwrites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:18:28 +03:00
avandClaude Opus 4.8 4475fbd548 Приём: гард дедуп-дозаписи хешей (F1) и апгрейд catched-magnet до torrent (F6)
Два дефекта дедуп-веток приёма (ревью Fable 2026-07-08), оба про инвариант
«≤1 активная загрузка на infohash» и сохранность источника.

F1: дедуп-ветка CreateDownloadIfNoActive дописывала все хеши входящего
источника в найденную активную задачу без пер-хеш гарда владения (в отличие
от AddInfohashes). Гибрид {v1,v2}, дедупнувшись на задачу B (владелец v2),
крал v1 у активной A → две активные владели v1. Теперь дозапись под тем же
гардом: хеш, которым владеет другая активная задача, не дописывается.

F6: при дедупе .torrent-байт на пойманную magnet-задачу (catched) байты
выбрасывались, source_type оставался magnet → worker добавлял по magnet-URL →
вечный metaDL → failed (magnet закрытого трекера без DHT метаданные не
докачает). Новый guarded-метод UpgradeCatchedMagnetToTorrent атомарно
сохраняет байты и меняет source_type magnet→torrent, но только пока задача в
catched (worker источник ещё не отдал). Ingest зовёт апгрейд на обоих
дедуп-путях. Это целевое исключение из правила спеки «при дедупе байты не
сохраняем» — оформлено MODIFIED-дельтой ingest.

Схема БД не меняется (download_torrent и source_type уже есть).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:18:28 +03:00
433 changed files with 32790 additions and 4427 deletions
+2 -2
View File
@@ -7,6 +7,8 @@ tags: [workflow, artifacts, experimental]
Implement tasks from an OpenSpec change. 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 (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps** **Steps**
@@ -46,8 +48,6 @@ Implement tasks from an OpenSpec change.
- If `state: "all_done"`: congratulate, suggest archive - If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation - Otherwise: proceed to implementation
**Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files.
4. **Read context files** 4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output. Read every file path listed under `contextFiles` from the apply instructions output.
+2 -2
View File
@@ -7,6 +7,8 @@ tags: [workflow, archive, experimental]
Archive a completed change in the experimental workflow. 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 after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps** **Steps**
@@ -29,8 +31,6 @@ Archive a completed change in the experimental workflow.
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other) - `artifacts`: List of artifacts with their status (`done` or other)
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
**If any artifacts are not `done`:** **If any artifacts are not `done`:**
- Display warning listing incomplete artifacts - Display warning listing incomplete artifacts
- Prompt user for confirmation to continue - Prompt user for confirmation to continue
+2
View File
@@ -11,6 +11,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
**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. **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.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be: **Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration" - A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy" - A specific problem: "the auth system is getting unwieldy"
+2
View File
@@ -16,6 +16,8 @@ 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 argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build. **Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps** **Steps**
+2 -2
View File
@@ -9,6 +9,8 @@ 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). 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 after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps** **Steps**
@@ -28,8 +30,6 @@ This is an **agent-driven** operation - you will read delta specs and directly e
openspec status --change "<name>" --json openspec status --change "<name>" --json
``` ```
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
3. **Find delta specs** 3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files. Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
+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.4.1"
---
Implement tasks from an OpenSpec change.
**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
**Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files.
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.4.1"
---
Archive a completed change in the experimental workflow.
**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 status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
**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
-287
View File
@@ -1,287 +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.4.1"
---
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.
---
## 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
-111
View File
@@ -1,111 +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.4.1"
---
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
---
**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.4.1"
---
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).
**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
```
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
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
+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}
+16 -6
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")
} }
@@ -182,9 +183,17 @@ func runServe(args []string) error {
if perr != nil { if perr != nil {
return perr return perr
} }
// Внутренние логи tgbotapi (сбои long-poll getUpdates и пр.) — в наш slog
// вместо stdlib log мимо структурированных логов; токен вырезается.
if lerr := tgbot.SetLibraryLogger(logger, cfg.Telegram.Token); lerr != nil {
logger.Warn("telegram library logger not set", "error", lerr)
}
api, terr := tgbotapi.NewBotAPIWithClient(cfg.Telegram.Token, tgbotapi.APIEndpoint, tgClient) api, terr := tgbotapi.NewBotAPIWithClient(cfg.Telegram.Token, tgbotapi.APIEndpoint, tgClient)
if terr != nil { if terr != nil {
logger.Error("telegram bot disabled, cannot connect", "error", terr) // NewBotAPIWithClient дёргает getMe: при недоступном Telegram/прокси
// terr — *url.Error с URL …/bot<TOKEN>/getMe; санитизируем, чтобы
// токен не утёк в лог.
logger.Error("telegram bot disabled, cannot connect", "error", logging.SanitizeErr(terr))
} else { } else {
bot := tgbot.New(api, ingestor, wrk, tgbot.Config{ bot := tgbot.New(api, ingestor, wrk, tgbot.Config{
AllowedUserIDs: cfg.Telegram.AllowedUserIDs, AllowedUserIDs: cfg.Telegram.AllowedUserIDs,
@@ -266,9 +275,10 @@ func metadataProviders(cfg *config.Config, logger *slog.Logger) ([]metadata.Prov
// пропускаем (сервис стартует), а не падаем. // пропускаем (сервис стартует), а не падаем.
if cfg.Metadata.TVDB.Enabled && cfg.Metadata.TVDB.APIKey != "" { if cfg.Metadata.TVDB.Enabled && cfg.Metadata.TVDB.APIKey != "" {
p, err := metadata.NewTVDB(metadata.TVDBConfig{ p, err := metadata.NewTVDB(metadata.TVDBConfig{
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)
@@ -280,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` — общем доме набора закреплённых значений, через который
идут и предпросмотр, и закрепление.
+43 -47
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 | лимита на размер ответа нет — единственный недоверенный канал без предела; задачей пока не заведено | — |
-70
View File
@@ -1,70 +0,0 @@
# Беклог
Единый список будущих задач по проекту: то, что уже решили сделать, и идеи,
которые ещё надо обдумать. Это **источник истины по беклогу** — одна задача = один
файл в этом каталоге. Не план реализации и не спецификация: принятое и
реализованное переезжает в [`docs/specs`](../specs)/[`docs/adr`](../adr), а сам
пункт беклога удаляется.
Приоритет — грубая оценка «ценность / стоимость», не обязательство к порядку.
Спекулятивные пункты (ещё без решения «делаем») помечены префиксом `[идея]` в
названии — их сперва надо проработать. Пункты, помеченные _(ревью 2026-07-08)_,
пришли из тщательного ревью ingest/worker/жизненного цикла (см. общий тег в теле).
Tududi (проект `jellybit`) больше **не** держит беклог — он служит только
инбоксом сырых идей. Прежде чем идея станет задачей, её оформляют файлом здесь.
## Высокий
- [Проблема второго сезона (сходимость папки сериала)](vtoroy-sezon-shodimost-papki.md) — Второй/третий сезон должен ложиться в ТУ ЖЕ папку сериала, а не заводить рядом почти…
- [Раздачи с докачиванием (merge при повторном добавлении)](merge-dokachivanie.md) — Свежий сериал раздают по мере выхода: торрент с 5 из 10 эпизодов позже перезаливают…
- [Полное удаление загрузки из jellybit («единое окно», path 2)](udalenie-edinoe-okno.md) — Действие «Удалить»: снять хардлинки + снести раздачу+файлы из qBittorrent → освободить место (отдельно от undo)
- [Ретеншн и очистка БД](retention-ochistka-bd.md) — Терминальные задачи (done/cancelled/failed/reverted), их попытки recognition с сырыми…
- [Eval-харнес распознавания (корпус кейсов + метрика точности)](eval-harness-raspoznavaniya.md) — Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую…
- [Гейт дозаписи хешей в dedup-ветке CreateDownloadIfNoActive (F1)](review-f1-gate-dozapisi-heshey.md) — dedup-ветка дописывает все хеши в найденную задачу без гарда — риск инварианта ≤1 активной _(ревью 2026-07-08)_
- [Retry/stall семантика: сброс базиса таймаута + простой от начала, а не от возраста торрента (MAJOR-1, MAJOR-2)](review-major1-2-retry-stall.md) — таймаут и простой отсчитываются от возраста торрента, а не от начала загрузки _(ревью 2026-07-08)_
- [Восстановление zombie downloading при пропаже источника из qBittorrent (MAJOR-3)](review-major3-zombie-downloading.md) — торрент пропал из qBittorrent в downloading → задача вечный зомби, никто не двигает _(ревью 2026-07-08)_
## Средний
- [Словарь единого языка (ubiquitous language)](ubiquitous-language-slovar.md) — Свести термины домена в один глоссарий, чтобы пользователь, документация, код и агент…
- [Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)](agenty-revyuvery-kachestva.md) — Набор узких сабагентов-ревьюверов поверх ревью-процесса из CLAUDE
- [[идея] Сила совпадения кандидата и пересмотр распознавания/матчинга](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
- [Sweep застрявших linking при рестарте + фикс persist-failure (MAJOR-4)](review-major4-sweep-linking.md) — задачи застревают в linking при рестарте; заодно фикс persist-failure _(ревью 2026-07-08)_
- [Defer из catched → лимбо → необратимый deleted (MAJOR-6)](review-major6-defer-catched.md) — Defer из ещё-не-добавленного catched уводит задачу в необратимый deleted _(ревью 2026-07-08)_
- [processCatched: promote-without-add если торрент уже в qBittorrent (F2)](review-f2-promote-without-add.md) — торрент уже в qBittorrent → processCatched зациклен на Add вместо promote _(ревью 2026-07-08)_
- [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 2026-07-08)_
- [transition() глотает ошибки перед созданием хардлинков (MINOR-7)](review-minor7-transition-errors.md) — transition() глотает ошибку перед хардлинками — задача застревает в review _(ревью 2026-07-08)_
- [.torrent поверх magnet при дедупе теряет байты — потерян upgrade-путь (F6)](review-f6-torrent-over-magnet.md) — .torrent поверх magnet при дедупе теряет байты — потерян upgrade-путь _(ревью 2026-07-08)_
## Низкий
- [Панель действий ревью вне htmx-свопа блока источника](panel-review-vne-swap.md) — При выборе источника одним кликом обновляется только блок источника (#source-block)…
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает…
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
- [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки)
- [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в…
- [Улучшения UI: показывать матч с записью метабазы в Telegram](telegram-match-metabazy.md) — Название/год/провайдер+id в боте уже выводятся; осталась кликабельная ссылка на запись
- [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: 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-приложение (устанавливаемое, отзывчивое…
- [Сделать фавиконку для jellybit](favicon.md) — мелкая косметика: иконка вкладки/PWA для веб-UI
- [Идентичность инфохэшей: split v1/v2 одного торрента + крафт-магнет отравляет владение (F4, F5)](review-f4-f5-infohash-identity.md) — split v1/v2 идентичность и крафт-магнет отравляют владение инфохэшами _(ревью 2026-07-08)_
- [Приём/UI: мелкие фиксы границ и парсинга (F7, F8, F9, F10, N2)](review-f7-f10-ingest-ui-fixes.md) — мелочи приёма/UI: oversized→500, гонки дедупа, парсинг magnet, cap контекста, руны _(ревью 2026-07-08)_
- [Жизненный цикл: мелкие находки (MINOR-8, MINOR-9, NIT-11, NIT-12)](review-lifecycle-minor.md) — claim-token распознавания, I/O под глобальным mutex, мелкие lookup/retry _(ревью 2026-07-08)_
- [Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)](review-ingest-nits.md) — косметика приёма: NoName в контексте, устаревшие комментарии, лог, bencode-аллокации _(ревью 2026-07-08)_
@@ -1,7 +0,0 @@
# Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)
**Приоритет:** средний
Набор узких сабагентов-ревьюверов поверх ревью-процесса из CLAUDE.md, каждый со своей оптикой: соответствие наименований словарю единого языка, соблюдение архитектурных границ (единое ядро/тонкие транспорты, инварианты безопасности данных), конвенций (ошибки, логирование, конфиг, TZ), стиля кода и поиск дублирования. Запускаются как чекпоинт перед archive/коммитом. Развивает ревью-процесс OpenSpec в сторону воспроизводимых автопроверок, не заменяя человеческое ревью.
Связано: CLAUDE.md (ревью-процесс, конвенции), docs/conventions, «Словарь единого языка».
@@ -1,7 +0,0 @@
# Аниме с абсолютной нумерацией
**Приоритет:** средний
Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт SxxEyy. Нужен пересчёт абсолютной нумерации в сезон/серию — надёжнее всего через TVDB (там есть absolute order). Отдельный крайний случай распознавания; на стороне ревью — веб-хелпер «absolute → S·E».
Связано: specs/recognition.md (конвейер, сезон-паки), specs/jellyfin-layout.md (нумерация серий), specs/review-ux.md.
-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,7 +0,0 @@
# Eval-харнес распознавания (корпус кейсов + метрика точности)
**Приоритет:** высокий
Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую: регрессий не видно. Нужен корпус размеченных кейсов (русские релизы, аниме, сезон-паки, репаки, спецвыпуски) и прогон распознавания по нему с метрикой точности (тип/название/год/нумерация). Тогда можно сравнивать LLM-провайдеры и версии промпта по числам. Прогон — отдельной командой (jellybit eval или тестом), на фикстурах, без реального qBittorrent.
Связано: specs/recognition.md (конвейер, модель уверенности), пакет recognize.
-5
View File
@@ -1,5 +0,0 @@
# Сделать фавиконку для jellybit
**Приоритет:** низкий
_Описание не заполнено._
@@ -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).
-7
View File
@@ -1,7 +0,0 @@
# Панель действий ревью вне htmx-свопа блока источника
**Приоритет:** низкий
При выборе источника одним кликом обновляется только блок источника (#source-block) htmx-свопом, а нижняя панель действий (кнопка «Применить», завязанная на HasLinks) — вне блока и не обновляется до полной перезагрузки. Практически не мешает (хардлинки только по явному «Применить», Apply без плана вернёт ошибку), но в краевом случае (источник с пустым предпросмотром из-за коллизии) кнопка может остаться/пропасть не синхронно. Решение намечено в дизайне review-unified-source-block: обновлять панель hx-swap-oob из того же партиала.
Связано: openspec/specs/review, openspec/specs/web-ui, пакет httpapi.
@@ -1,13 +0,0 @@
# Гейт дозаписи хешей в dedup-ветке CreateDownloadIfNoActive (F1)
**Приоритет:** высокий · **Теги:** ingest, review-2026-07-08, invariant
Ревью Fable 2026-07-08 (приём). internal/store/download.go:271-290.
Проблема: dedup-ветка CreateDownloadIfNoActive безусловно дописывает ВСЕ хеши norm в найденную активную задачу (INSERT OR IGNORE) без пер-хеш гарда владения — в отличие от AddInfohashes (download.go:382-418), у которого гард есть. Единственная неохраняемая запись хешей — в авторитетном методе инварианта.
Сценарий: активная A владеет v1, активная B владеет v2 того же торрента (split-identity, см. F4) ИЛИ крафт-магнет (F5) → гибрид {v1,v2} дописывает v1 в B → две активные владеют v1. Инвариант «≤1 активная на infohash» нарушен. Спека ingest «Атомарность возврата в активное» это запрещает.
Фикс: применить пер-хеш гард как в AddInfohashes (исключить existing.ID, пропускать хеши чужой активной задачи). Tx уже открыта.
Вердикт: простой фикс (поведение уже обещано спекой).
@@ -1,13 +0,0 @@
# processCatched: promote-without-add если торрент уже в qBittorrent (F2)
**Приоритет:** средний · **Теги:** ingest, review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (приём). worker.go:361-391, sourceAddParts :407-432, qbt.go:243-246.
Сценарий: торрент уже в qBittorrent БЕЗ нашей категории/тега (юзер добавил вручную раньше → discover не усыновляет). Юзер грузит тот же .torrent в jellybit → catched → qbt.Add файлом; для file-add дубль → «Fails.» → Add ошибка → «will retry» каждый тик, вечно, до catch_timeout → failed/qbit_add, который reconcileRecovery НЕ воскрешает (worker.go:124-132). Торрент жив всё это время; юзер видит failed. Retry уже решает это alive-проверкой (worker.go:692-698 «повторный Add вреден»), а processCatched — нет, хотя live-снимок byHash того же тика доступен. Также лечит сценарий B (Add успех, PromoteCatched падает на транзиентной ошибке → снова Add дубля).
Замечание: поведение qBit на дубль file-add версионно-зависимо («Fails.» vs «Ok.») — проверить на целевой версии.
Фикс: перед Add проверить присутствие хешей в qBit; есть → promote без Add (зеркалит Retry).
Вердикт: change (малая спека-дельта download-tracking + код).
@@ -1,11 +0,0 @@
# Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (оба ревьюера: F3 + NIT-13). worker.go:368-390, discover.go:43-50.
Сценарий: задача catched, worker вне w.mu выводит имя (LLM, секунды) + qbt.Add (успех). Параллельно user Cancel (catched→cancelled). PromoteCatched корректно пропускает (гард state='catched', спека соблюдена). НО торрент ДОБАВЛЕН в qBit под нашей категорией, будет качаться/сидировать вечно. Усыновить назад нельзя: adopt через ExistsByInfohash (любое состояние) → хеши cancelled-задачи существуют. Торрент ест диск без видимой задачи и владельца. Спека покрывает переход состояния, но не побочный эффект. Инвариант «источник неприкосновенен» — но этот торрент добавили МЫ после cancel-намерения.
Фикс-опции: (a) re-read state прямо перед Add (сужает окно); (b) при promote-skip из-за cancel — WARN «torrent left in qBittorrent»; (c) scoped delete/pause только что добавленного нами.
Вердикт: change (нужно решение по инварианту «источник неприкосновенен»).
@@ -1,11 +0,0 @@
# .torrent поверх magnet при дедупе теряет байты — потерян upgrade-путь (F6)
**Приоритет:** средний · **Теги:** ingest, review-2026-07-08
Ревью Fable 2026-07-08 (приём). ingest.go:85-90, download.go:252-253, спека ingest «при дедупликации байты сохраняться SHALL NOT».
Сценарий: magnet с приватного трекера → catched, source_type=magnet. Юзер понимает, что magnet не докачает метаданные (нет DHT), грузит правильный .torrent. Ingest дедупит по infohash на magnet-задачу; по спеке блоб НЕ сохраняется, source_type остаётся magnet. Worker добавляет по magnet-URL → metaDL вечно → failed/magnet_timeout через 24ч. Юзер дал именно артефакт, который бы починил, — выброшен с «уже в работе». Retry снова по magnet. Рационал самой спеки (хранить байты, «иначе на закрытых трекерах не докачать») спорит с её же правилом дедупа здесь.
Фикс: при дедупе, где входящее — torrent-байты, а existing — catched с source_type=magnet: сохранить блоб и сменить source_type в той же tx.
Вердикт: change (противоречит текущему предложению спеки, нужна дельта).
@@ -1,17 +0,0 @@
# Приём/UI: мелкие фиксы границ и парсинга (F7, F8, F9, F10, N2)
**Приоритет:** низкий · **Теги:** ingest, review-2026-07-08
Ревью Fable 2026-07-08 (приём). Пачка независимых простых фиксов.
F7 — oversized .torrent через веб → 500 вместо 400. ingest.go:148-151 отдаёт plain fmt.Errorf «torrent too large», classifyErr (httpapi.go:753-770) → default → 500. Web MaxBytesReader пропускает MaxTorrentSize+1MiB. Фикс: sentinel-ошибка размера → 400. (Telegram ок — pre-check doc.FileSize.)
F8 — fast-path attach гонка с cancel. ingest.go:85-90,205-217: FindActiveByInfohash (без tx) вернул catched, параллельно cancel → attached() отдаёт Deduplicated=true (stale «уже в работе»), ничего не активно. Фикс: убрать ранний return, дедуп-решение только в CreateDownloadIfNoActive (уже re-check под BEGIN IMMEDIATE).
F9 — magnet parsing. magnet.go:46-51: HasPrefix «urn:btih:» регистрозависим → magnet:?xt=URN:BTIH:… отклоняется (RFC 2141: URN регистронезависим; scheme уже матчится EqualFold :91). tgbot/parse.go:11: magnet:\?[^\s]+ приклеивает хвостовую пунктуацию (точка/скобка) → xt последним → длина 41 → отказ. Фикс: case-insensitive префикс; trim хвостовых .,;:)]}>» в ParseMessage.
F10 — cap размера context из веб-формы. httpapi.go:405,412-414: multipart-бюджет на всё тело; без файла поле context ~9MiB → download.context в БД, рендер, LLM-промпты. REST 64KiB, Telegram лимит подписи — открыт только web. Фикс: cap Context в Ingest (одно место) ~16KiB с маркером.
N2 — shorten() режет по байтам не рунам (httpapi.go:713-718): кириллические source_ref-заголовки (частый случай) рвутся посреди руны → U+FFFD. Фикс: рунобезопасная обрезка.
Вердикт: все простые фиксы.
-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-путей там не найдено.)
Вердикт: простые фиксы/принять.
-15
View File
@@ -1,15 +0,0 @@
# Жизненный цикл: мелкие находки (MINOR-8, MINOR-9, NIT-11, NIT-12)
**Приоритет:** низкий · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). Мелкие находки.
MINOR-8 — recognition claim-token отсутствует. review.go:160-177: задача в recognizing, LLM в полёте (вне w.mu); user Cancel→Relink (recognizing→cancelled→recognizing revive). Старый вызов завершается, finishRecognition re-check d.State==recognizing — true, но это НОВЫЙ claim → устаревший результат коммитится (recognizing→review); свежий прогон отбрасывает себя. Импакт низкий (входы почти идентичны), но потерянный LLM-вызов + результат приписан не той попытке. Фикс: claim-token (updated_at на момент claim, или id строки recognition).
MINOR-9 — I/O под глобальным w.mu. worker.go:677-734 (Retry: torrentByInfohash + qbt.Add под mu), review.go: ensureSourcePresent (сеть) в Relink/Rerecognize/Refine/SetType, Apply (torrentByInfohash + layouter.Apply FS I/O), Undo (FS I/O) — держат w.mu целиком. Нарушает своё же правило «медленные вызовы вне блокировки» (processCatched/recognizeOne его соблюдают). Медленный/зависший qBit → каждый клик ревью = глобальный стопор поллинга и всех команд транспортов. Корректность ок (lock даёт race-free preflight), только liveness. Для one-user home-сервера — можно осознанно waive; худший — Retry с qbt.Add под mu при зависшем qBit. Фикс если делать: probe-вне-lock + re-validate-под-lock (как processCatched).
NIT-11 — byHash индексирует и усечённый 40-hex t.Hash v2-only торрентов (worker.go:444-452). Хранение усечённых хешей отклоняется (discover.go:98-111), но lookup-мапа всё ещё ключует t.Hash → v1-хеш одной задачи теоретически == усечённый-v2 t.Hash другого торрента → матч не тому. Требует 160-бит коллизию (космологические шансы). Фикс: исключить t.Hash из мапы, когда есть infohash_v1/v2 (зеркалить torrentHashes).
NIT-12 — Retry на failed/qbit_error (missingFiles) с живым errored-торрентом: alive=true → без re-Add → downloading → следующий тик classErrored → снова failed (+ дебаунс уведомления). Честно, но user-hostile: retry выглядит сломанным, реальное лекарство (recheck/fix в qBit) не подсказано. Фикс: отклонять retry (или форсить recheck) когда живой торрент classErrored. (Связано с задачей retry/stall семантики.)
Вердикт: MINOR-8/NIT-11/NIT-12 — простые фиксы; MINOR-9 — change или осознанный waive.
@@ -1,13 +0,0 @@
# Retry/stall семантика: сброс базиса таймаута + простой от начала, а не от возраста торрента (MAJOR-1, MAJOR-2)
**Приоритет:** высокий · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). Два связанных бага. worker.go:677-734 (Retry), :514-525 (checkTimeouts), :578-592 (torrentAge).
MAJOR-1: Retry с живым торрентом (alive=true) не переиздаёт Add, только ActivateIfNoOtherActive→downloading; базис age=nowadded_on НЕ сбрасывается → следующий тик: stalledDL && age>StuckAfter → снова stuck (~5с). Спека state-reconciliation «Ручной повтор» требует: базис SHALL сбрасываться. Комментарий worker.go:690-691 верен лишь для re-Add ветки. Тест TestRetryReattaches не гоняет следующий тик.
MAJOR-2: stuck_after меряет ВОЗРАСТ торрента (от added_on), а не длительность простоя. Торрент, качавшийся 5ч, при мгновенном stalledDL на 1 тик → stuck с сообщением «stalled for 5h» (ложь) + EventFailed. Проход через stalledDL между пирами — норма → флап stuck↔downloading + до-часовые ложные уведомления. Спека сама противоречива («stalledDL дольше stuck_after» vs «возраст от added_on»).
Фикс: колонка retried_at и/или stalled_since (или qBit last_activity); базис = max(added_on, retried_at); простой мерить от stalled_since. Схема + миграция + сверка спеки. Покрывает также NIT-10 (фолбек added_on→created_at) и NIT-12 (retry на qbit_error мгновенно откатывается).
Вердикт: полноценный change (схема + спека). Бьёт по повседневным сценариям — retry выглядит сломанным, длинные загрузки спонтанно флапают в stuck.
@@ -1,11 +0,0 @@
# Восстановление zombie downloading при пропаже источника из qBittorrent (MAJOR-3)
**Приоритет:** высокий · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). worker.go:472-477. Подтверждено чтением кода.
Сценарий: торрент удалён из qBittorrent (юзером/другим клиентом), пока задача в downloading. Poll: torrentFor промах → Warn «active download not found in qbittorrent» → continue. Каждый тик, вечно. reconcileDesync покрывает только done/target_missing/orphaned; reconcileRecovery — failed/stuck; дебаунса для этого случая НЕТ, состояние не меняется, уведомления нет, checkTimeouts требует торрент. Задача — вечный зомби, активна в UI без телеметрии; выход только Cancel/Defer. Тот же зомби при провале отката Retry (worker.go:716-729). Спека намеренно исключает активные из матрицы source×target, но «источник исчез в downloading» не владеет НИКТО — дыра спеки (сравн.: та же пропажа в completed/recognizing деградирует штатно).
Фикс: расширить дебаунс пропажи источника (SourceMissCount) на downloading → после порога downloading→deleted (или failed с distinct error_code для re-Add) + уведомление. Нужно ребро графа.
Вердикт: полноценный change. Классический «застрявшее состояние, которое никто не двигает».
@@ -1,13 +0,0 @@
# Sweep застрявших linking при рестарте + фикс persist-failure (MAJOR-4)
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). review.go:284-287.
(A) Без краха: linkPlan создаёт хардлинки на FS, затем CreateFileLinks падает (транзиентная ошибка SQLite) → return без перехода → задача в linking, хардлинки на диске без file_link-строк (Undo нечего откатывать, targetPresent=false).
(B) Краш процесса между transition(StateLinking) (review.go:251) и финальным переходом → на рестарте linking не листит НИКТО (processCatched=catched, Poll=downloading, recognizePending=completed/recognizing, desync=done/tm/orphaned, recovery=failed/stuck). Задача сидит в linking вечно; выход только ручной Cancel/Defer (недискаверабельно). recognizing получил restart-healing (recognizePending), linking — нет — нарушен инвариант «у каждого нетерминального состояния есть владелец».
Фикс: на тике/старте sweep linking-задач (любая под w.mu — по построению устаревшая) → linking→review (ребро есть) с error_msg «прерванная раскладка, повтори»; при persist-failure переходить в review/failed, а не bare-return.
Вердикт: простой фикс (+ 1 спека-сценарий).
@@ -1,11 +0,0 @@
# Defer из catched → лимбо → необратимый deleted (MAJOR-6)
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). review.go:465-479, worker.go:332-340, reconcile.go:251-265.
Сценарий: задача в catched (ещё не добавлена в qBit) → user Defer → deferred. processCatched больше её не видит (листит только catched) → торрент никогда не добавится. Из deferred: Apply→«нет плана», Rerecognize/Refine→ensureSourcePresent нет торрента→reconcileToReality(sourcePresent=false, targetPresent=false)→deriveState=deleted, а у deleted НОЛЬ исходящих рёбер (download.go:102) → задача необратима, хотя байты .torrent лежат в download_torrent. Также deleted семантически неверен (ничего не качалось/раскладывалось).
Фикс: исключить catched из Defer (пре-источниковое состояние, «позже» бессмысленно) ИЛИ processCatched резюмит deferred-без-recognition ИЛИ preflight «источника не было никогда» (source_added_at IS NULL и нет links) → failed/qbit_add (retriable), не deleted.
Вердикт: простой фикс (исключить catched из Defer).
@@ -1,11 +0,0 @@
# transition() глотает ошибки перед созданием хардлинков (MINOR-7)
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). worker.go:595-601 (ошибка логируется, не возвращается), review.go:251-252, :196-198.
Сценарий: в Apply w.transition(StateLinking) на транзиентной ошибке БД → залогировано, выполнение продолжается → linkPlan создаёт хардлинки, пока задача ещё в review. Финальная запись linking→done оценивается как review→done — НЕ в графе → отклонена → файлы на диске, задача застряла в review со stale-планом; file_link-строки есть (re-Apply увидит StatusExists, частично самолечится), но done не достигнут, скан/уведомление не сработали. Паттерн claim-then-side-effect корректен только если claim проверяется (везде ещё — PromoteCatched, finishRecognition — гейтят; тут нет).
Фикс: transition возвращает ошибку (или mustTransition); Apply/finishRecognition прерываются до linkPlan при провале claim.
Вердикт: простой фикс.
@@ -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, «Проблема второго сезона», «Раздачи с докачиванием».
-7
View File
@@ -1,7 +0,0 @@
# Улучшения UI: показывать матч с записью метабазы в Telegram
**Приоритет:** низкий
Web-сторона реализована: страница загрузки /download/{id} и экран ревью показывают, с какой именно записью метабазы (TMDB/TVDB/IMDb) сматчилась загрузка — провайдер, id и ссылку. Осталось довести то же в Telegram: в уведомлениях/подтверждениях показывать запись матча (название, год, провайдер-id, ссылку), чтобы ошибочную привязку было видно и из бота. Полный выбор источника в вебе уже реализован.
Связано: specs/review-ux.md, specs/recognition.md (матч в базе), specs/architecture.md → «Транспорты».
-7
View File
@@ -1,7 +0,0 @@
# Выбор из нескольких находок метабазы в Telegram
**Приоритет:** низкий
Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча брать первый/лучший. Веб остаётся точкой точных правок (полный выбор источника уже реализован), бот — быстрый выбор из готового короткого списка.
Связано: specs/review-ux.md (боты — быстрые действия, веб — точные правки), specs/recognition.md (кандидаты матча).
-52
View File
@@ -1,52 +0,0 @@
# Полное удаление загрузки из jellybit («единое окно», path 2)
**Приоритет:** высокий
Безопасная половина «единого окна» уже в проде: распознавание ручного удаления и
пометка рассинхрона (state-reconciliation — `target_missing`/`orphaned`/`deleted`,
undo с nlink-гардом, preflight). Осталась вторая половина — **удалять из самого
jellybit**, не идя руками в qBittorrent/Jellyfin.
Основной сценарий одного окна — «досмотрел → освободить место»: удалить и раздачу,
и файлы в библиотеке разом. Из-за хардлинков иначе место и не вернуть — файл в
`downloads/` (qBittorrent) и наша ссылка в библиотеке указывают на **один инод**,
диск освобождается только когда исчезает последняя ссылка. Значит delete обязан
снять **обе** стороны.
## Действие «Удалить» (одна загрузка)
- Снять живые хардлинки загрузки (переиспользуем undo-механику: `superseded`
пропускаем) **+** удалить раздачу с файлами из qBittorrent (новый метод в `qbt`,
`deleteTorrents` с `deleteFiles=true`).
- **Осознанно обходим** предохранитель последней копии (undo при «источник удалён,
цель — последняя копия» отказывает — `worker/review.go:499`; здесь мы наоборот
хотим снять последнюю копию).
- Обязательное **подтверждение** — это выход за инвариант «источник
неприкосновенен», не по случайному клику. Логировать как осознанное удаление
источника.
## undo vs delete (зафиксировать различие)
- **undo** — «перераспознать»: снимает только наши ссылки, раздачу в qBittorrent
сохраняет, данные бережёт (гард включён). Уже готов.
- **delete** — «убрать окончательно, освободить место»: снимает наши ссылки И
сносит раздачу+файлы, гард выключен, состояние терминальное.
## Терминальное состояние
Переиспользуем существующий `deleted` (исход тот же: и источник, и цель сняты) —
**без** нового статуса. Инициатора (`user` против reconciliation) пишем в причину/
лог перехода. В граф переходов добавить явное пользовательское ребро
`done → deleted` (сейчас `deleted` выводит только сверка с реальностью).
## Опционально (если понадобится)
Мультивыбор в списке + «удалить выбранное» — тонкая обёртка над тем же действием
для редкого случая «снести сериал целиком». Вычисляемую группу-«тайтл» и экран
состава **не** делаем — оверинжиниринг ради редкого сценария.
Оформить как OpenSpec-change (дельта `state-reconciliation`/`review` + метод `qbt`
+ ребро графа + action в UI/боте).
Связано: drafts/logical-title-model.md §5.3/§6.4, ADR-2026-06-13-hardlinks,
openspec/specs/state-reconciliation, specs/workflow.md, пакеты qbt, worker, layout.
@@ -1,13 +0,0 @@
# Проблема второго сезона (сходимость папки сериала)
**Приоритет:** высокий
Второй/третий сезон должен ложиться в ТУ ЖЕ папку сериала, а не заводить рядом почти одинаковую. Проблема не в группировке, а в сходимости папки: имя печатается заново из выхода LLM, совпадение provider_id не гарантирует совпадение строки («Fargo» vs «Фарго», год сезона vs год сериала). Отдельная сущность «тайтл» НЕ вводится. Решение — правило сходимости при построении плана: при подтверждённом матче наследовать базу папки (имя+год) от живых file_link загрузок с тем же (provider, provider_id), игнорируя LLM-выход; якоря нет → папка из распознавания, как сейчас.
Шаги:
- lookup живых ссылок по (provider, provider_id) через current recognition
- наследование базы папки (имя+год) при построении плана раскладки
- рассинхрон (несколько живых папок с одним матчем) → review, не молча
- тесты: сходимость, отсутствие якоря (свежая папка), смена провайдера
Связано: drafts/logical-title-model.md §5.2, specs/recognition.md, specs/jellyfin-layout.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.
+63 -14
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,15 +66,65 @@ jellybit — **приложение, а не библиотека**: внешн
- **+ корреляционный ключ** для владельца — `download_id` (если операция - **+ корреляционный ключ** для владельца — `download_id` (если операция
к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах. к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах.
Пример: «При обработке загрузки произошла ошибка, download_id=12345», а Пример: «При обработке загрузки произошла ошибка, download_id=12345», а
не «произошла ошибка» и не сырой текст; не «произошла ошибка» и не сырой текст.
- **маппинг доменной ошибки → статус/сообщение**: `ErrNotFound` → 404 **Ключ есть не у всякого транспорта, и это называется вслух.** `request_id`
«не найдено», валидация/`ErrNotMagnet` → 400 «некорректный источник», — понятие HTTP-границы (chi `RequestID`); у Telegram и CLI его нет. Если
конфликт состояния (`ErrConflict` — операция недопустима в текущем операция ещё не завела загрузку (отказ приёма), у такого транспорта ключа
состоянии) → 409 «действие недоступно в текущем состоянии», прочее → нет вовсе — тогда сообщение остаётся без якоря, а диагностика ищется по
500 «внутренняя ошибка». записи доменной границы (`capability`, `infohash`). Заводить транспорту
собственный идентификатор запроса ради ключа — решение уровня спеки, а не
умолчание: второй канал корреляции рядом с существующим дороже, чем
отсутствие ключа;
- **маппинг доменной ошибки → статус/сообщение** (в jellybit —
`httpapi.classifyErr`, единая точка для REST и веб-UI):
Граница публичная по умолчанию. Истинно приватный для владельца канал — | Доменная ошибка | Статус | Сообщение |
логи; отдельной «операторской» поверхности с сырыми ошибками не заводим. |---|---|---|
| `store.ErrNotFound` | 404 | «не найдено» |
| `magnet.ErrNotMagnet` / `torrent.ErrNotTorrent` | 400 | «некорректный источник» |
| `ingest.ErrTorrentTooLarge` (файл больше лимита) | 400 | «файл .torrent слишком большой» |
| `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» |
| `errManualSource` (ручной ввод источника, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
| `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
| `errBatchEmpty` / `errBatchTooLarge` / `errBatchBadID` (разбор пачки группового удаления, локальные sentinel'ы `httpapi`) | 400 | текст самой ошибки |
| `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» |
| `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» |
| `layout.ErrNameTooLong` (целевое имя не помещается, ушло в review) | 409 | «целевое имя слишком длинное…» |
| `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» |
| прочее | 500 | «внутренняя ошибка» |
Новую штатную ветвь отказа (конфликт/валидация) заводим sentinel’ом и
добавляем сюда — иначе `default` отдаст 500 «внутренняя ошибка» на
нормальный конфликт (и логирующая граница спишет его в `ERROR` вместо
`DEBUG`, см. [logging.md](logging.md)).
### Транзиентный ответ vs персистентная диагностика
У публичной границы две разные поверхности, и правило сырого текста для них
разное:
- **Транзиентный ответ на действие** (тело REST/`?err=`/answer бота по
результату команды) — строго нейтральный: маппинг выше, `err.Error()` наружу
не идёт, полная ошибка — в логах по `download_id`/`request_id`.
- **Персистентная диагностика состояния** — `error_msg` перехода (причина ухода
в review/failed: коллизия, рассинхрон, сбой ФС) и `reasons` распознавания,
сохранённые в БД и показываемые на экране ревью и в Telegram-карточке. Это
**операторская поверхность владельца**: сервис однопользовательский в
доверенном контуре (см. [security.md](../security.md) → «Периметр»), эти поля —
диагностический контекст для того, кто разбирает задачу. Здесь сырой текст
ошибки (пути, фрагмент ответа LLM/qBittorrent) **допустим и полезен** — но:
- **секреты запрещены** абсолютно (токены/ключи/пароли/`Authorization`) — так
же, как в логах ([logging.md](logging.md), «Безопасность»). Источник
error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок
транспорта, несущих URL с секретом);
- это **не** канал для транзиентных отказов команд — те остаются нейтральными
(см. выше);
- **внешнее значение в тексте усекается на границе, а его размер называется
числом.** `error_msg` уезжает в баннер ревью, в панель действий и в карточку
Telegram; имя файла на 400 байт занимает там экран целиком и оседает в БД
навсегда. Усечение — серединой и по рунам (`layout.shorten`,
`naming.truncate`, `tgbot.shorten`), точная величина остаётся числом рядом:
без неё человек не поймёт, насколько сокращать.
## panic ## panic
+72 -37
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,21 +26,18 @@ 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`), не в текст.
- **Смена состояния загрузки — единая категория `state transition`** с полями
`from`/`to`/`code` (какое именно состояние и по какой причине — это данные,
не текст). Любой переход (в т.ч. `cancel`/`retry`/`relink`) пишет этот
`msg`, чтобы весь жизненный цикл собирался одним фильтром: `jq
'select(.msg=="state transition" and .download_id=="…")'`. Физический эффект
сверх перехода — отдельная запись своей категории (`layout linked`,
`layout reverted`, `review hint added`), не подменяет запись перехода.
## Уровни ## Уровни
@@ -75,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); бизнес-логика в локальной зоне не работает.
## Поля: словарь имён ## Поля: словарь имён
@@ -124,30 +124,51 @@ 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`.
- Логируем ошибку **один раз — на границе доменного слоя** (use-case - Логируем ошибку **один раз — на границе доменного слоя**, которая
`Ingest`, стадии воркера), которая определяет исход операции: полем определяет исход операции: полем `error`. В Go логирует этот единый
`error`, уровень `ERROR`. В Go логирует этот единый чокпоинт, а не каждый чокпоинт, а не каждый транспорт — так транспорты остаются тонкими. Границы
транспорт — так транспорты остаются тонкими. в jellybit:
- use-case `Ingest` (приём);
- **асинхронные стадии воркера** (поллинг, распознавание, авто-раскладка) —
исход стадии, вызванной таймером/циклом;
- **публичные команды воркера** (`Apply`/`Refine`/`Cancel`/`Retry`/`Undo`/
`Delete`/…), вызываемые транспортами. Исход команды логирует ровно один
чокпоинт (`worker.logCmd`, в `defer` при именованном возврате), а не
HTTP/web/Telegram — они одну и ту же команду зовут из трёх мест.
- Транспорты (HTTP/web/Telegram) переводят возвращённую ошибку в свой ответ - Транспорты (HTTP/web/Telegram) переводят возвращённую ошибку в свой ответ
(статус, сообщение пользователю) и **не логируют** её повторно — иначе (статус, сообщение пользователю) и **не логируют** её повторно — иначе
один сбой даёт дубли. один сбой даёт дубли.
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой
доменной ошибки ровно один логирующий; уровень выбирает он. На **границе
команды** (пользователь инициировал действие и ждёт ответа — `worker.logCmd`):
| Класс отказа | Кому | Уровень |
|---|---|---|
| штатный конфликт состояния / некорректный ввод (`ErrConflict`, `ErrNotReady`, `ErrInvalidInput`, `ErrNotFound`, `layout.ErrCollision`) | пользователю (уже получил ответ на поверхности) | `DEBUG` |
| нарушенный инвариант хранилища/учёта (не безопасность данных: файлы уже разложены) | команде, «может стать проблемой» | `WARN` |
| сбой БД / ФС / недоступность зависимости | команде, в разбор | `ERROR` |
Тот же класс отказа в **асинхронной стадии** (пользователь не ждёт: авто-
раскладка, поллинг) адресован уже команде как деградация автоматики — уровень
поднимается. Пример: `layout.ErrCollision` в ручном `Apply``DEBUG` (человек
видит причину в карточке), а в авто-раскладке — `WARN` («auto-apply failed,
left for review»): автоматика не довела задачу, это «может стать проблемой».
- **Повторяющийся сбой фонового цикла (поллинг/сверка) — `WARN`, не `ERROR`.**
Одиночный промах тика (`poll`/`sweep`/`list failed`, недоступный
qBittorrent) транзиентен: следующий тик повторит. Тот же класс сбоя внутри
синхронной операции (`ingest.Ingest`) — `ERROR`, потому что операция
провалилась целиком и повтора нет. То есть уровень задаёт не текст ошибки, а
наличие штатного ретрая: тик повторится → `WARN`, разовая операция упала →
`ERROR`. (Устойчивый сбой N тиков подряд эскалировать в `ERROR` — на будущее,
сейчас не реализовано.)
- Телеметрия внешнего вызова (`ext.*`, см. ниже) — отдельная запись о - Телеметрия внешнего вызова (`ext.*`, см. ниже) — отдельная запись о
поведении зависимости, не дубль доменной ошибки. поведении зависимости, не дубль доменной ошибки.
- Глушить ошибку без лога — только с однострочным комментарием «почему». - Глушить ошибку без лога — только с однострочным комментарием «почему».
@@ -183,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`. Если запрос порождает загрузку — связь даёт
@@ -206,6 +232,15 @@ log.Error(err.Error())
быть большим) — только на `DEBUG`, с вычисткой секретов и обрезкой по длине. быть большим) — только на `DEBUG`, с вычисткой секретов и обрезкой по длине.
- При сомнении — не логируем значение, логируем факт его наличия - При сомнении — не логируем значение, логируем факт его наличия
(`"has_api_key", true`). (`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — потенциальный носитель секрета.**
`*url.Error` (стандартный `net/http`) встраивает полный URL запроса, а
секрет может жить прямо в нём: токен Telegram в пути (`…/bot<TOKEN>/…`),
`api_key` метабазы в query. Санитизируем на границе клиента **до** лога и
обёртки — `logging.SanitizeErr(err)` разворачивает `*url.Error` в
первопричину (URL отбрасывается, `errors.Is` на причину сохраняется).
Применяется в `ext.*`-обёртке (`ExtCall`), клиентах metadata и tgbot. Общее
правило: **секрет не кладём в URL, если у API есть заголовок** — тогда его
нет и в ошибке транспорта.
## Куда пишем и уровень ## Куда пишем и уровень
+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` — при желании свести в один
короткий инвариант.
-286
View File
@@ -1,286 +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 — вставить, когда захочется таймлайн/метрики)
```
Каждый шаг — отдельный 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, в которые раскладываем.
-274
View File
@@ -1,274 +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
После успешной раскладки (вход в `done`) `worker` неблокирующе просит Jellyfin
пересканировать медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
Включается конфигом `[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`; опц.,
включается `[jellyfin]`.
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
- Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
- Форма запуска — docker, образ собирается на сервере; контейнер под
`1000:1000`, в общей docker-сети, mount `/srv/media` + data-том.
## Открытые вопросы
- (пока нет)
-156
View File
@@ -1,156 +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`-файла).
Назначение таблиц и почему так — [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 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 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`).
-102
View File
@@ -1,102 +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`.
## Сопоставление источник → цель
Источник берём по пути из 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)
в беклоге.
-166
View File
@@ -1,166 +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)** → переключатель пересобирает форму плана.
- **Мусор (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.
-194
View File
@@ -1,194 +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
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: сверка — источник пропал
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
«Отклонить» доступно из любого
нетерминального состояния
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` (убрать созданные ссылки).
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
(ретраибельна); «Отклонить».
- **reverted / cancelled → recognizing** — «Привязать заново»: после
отката или отклонения можно перезапустить распознавание для той же
раздачи. Перепривязка всегда идёт через review с ручным подтверждением
(авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в
qBittorrent.
## Сверка с реальностью (рассинхрон)
Состояние в БД может разойтись с диском при **ручном** удалении: раздачу
стирают из qBittorrent (источник) или файлы убирают из Jellyfin (целевые
хардлинки). `worker` периодически сверяет уже разложенные задачи с фактом по
двумерной матрице «источник × цель» (источник = раздача в qBittorrent,
цель = разложенные хардлинки на ФС) и выводит состояние:
- **target_missing** — источник на месте, цель удалена. Доступна команда
«Привязать заново» (`→ recognizing`); авто-действий нет.
- **orphaned** — источник пропал, цель (последняя копия данных) на месте.
Команд вперёд нет; `Undo` запрещён (снял бы единственную копию).
- **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` (медленные трекеры/мало пиров) — это
норма, его не убиваем агрессивно. Возраст считаем от времени добавления
торрента в qBittorrent (`added_on`), а не от создания задачи (базис
переживает retry и усыновление).
- **ошибка:** `error`/`missingFiles``failed` (`error_code` `qbit_error`) —
это настоящий провал, в отличие от таймаута.
### Уведомление и восстановление
- Любой переход в `failed`/`stuck` **уведомляет** автора загрузки
(`notifier`), чтобы падение не оставалось незамеченным — включая приёмное
падение `qbit_add` (не удалось добавить в qBittorrent), которое идёт мимо
поллинг-цикла. Повторные падения одной задачи в пределах окна дебаунса
уведомляют лишь раз — чтобы мерцающий `stalled`-торрент
(`stuck``downloading`) не спамил.
- `failed`/`stuck` из-за нашей нетерпеливости (`error_code` `magnet_timeout`/
`stalled`) **не тупик**: фоновая сверка возвращает задачу в поток, как
только источник в qBittorrent ожил и продвинулся за условие падения
(получил метаданные → `downloading`; уже готов → `completed`). Пока торрент
всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания).
Настоящие провалы (`qbit_error`) сверкой не воскрешаются.
- Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только
REST): возвращает в `downloading`, перецепляясь к живому торренту без
повторного `Add`.
Пути файлов берём из 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")
}
+37 -10
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) {
+164 -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"
@@ -10,6 +10,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
) )
@@ -25,6 +27,8 @@ func (s stubCommander) Retry(context.Context, string) error { return s.retryErr
type actionReviewer struct { type actionReviewer struct {
stubReviewer stubReviewer
undoErr error undoErr error
deleteErr error
dismissErr error
relinkErr error relinkErr error
rerecognizeErr error rerecognizeErr error
refineErr error refineErr error
@@ -32,6 +36,8 @@ type actionReviewer struct {
} }
func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr } func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr }
func (a actionReviewer) Delete(context.Context, string) error { return a.deleteErr }
func (a actionReviewer) Dismiss(context.Context, string) error { return a.dismissErr }
func (a actionReviewer) Relink(context.Context, string) error { return a.relinkErr } func (a actionReviewer) Relink(context.Context, string) error { return a.relinkErr }
func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr } func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr }
func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error { func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
@@ -44,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,
@@ -268,8 +274,73 @@ func TestActionErrorDownloadSurface(t *testing.T) {
} }
} }
// TestRetryListShowsProgress: retry из списка → карточка downloading с // reviewDataWithSource собирает ReviewData ревью с одним нейронка-источником;
// прогресс-поллером. // withLinks управляет наличием превью раскладки (HasLinks).
func reviewDataWithSource(withLinks bool) *worker.ReviewData {
dl := dlState(store.StateReview)
plan := recognize.Plan{Type: "movie", Title: "Fargo", Year: 1996,
Files: []recognize.PlanFile{{Src: "fargo.mkv"}}}
rd := &worker.ReviewData{
Download: dl,
Recognition: &store.Recognition{},
Plan: plan,
Sources: []worker.SourceOption{
{Kind: worker.SourceNeural, Provider: "none", Title: "Fargo", Year: 1996, Active: true},
},
}
if withLinks {
rd.Preview = []layout.Link{{Src: "fargo.mkv", Dst: "/movies/Fargo (1996)/fargo.mkv"}}
}
return rd
}
// TestSourceSwapUpdatesActionBarOOB: своп выбора источника отдаёт свежий
// #source-block и oob-обновление панели действий (#action-bar с hx-swap-oob),
// чтобы кнопка «Применить» синхронно отражала актуальный HasLinks.
func TestSourceSwapUpdatesActionBarOOB(t *testing.T) {
t.Run("с превью → есть кнопка Применить", func(t *testing.T) {
rd := reviewDataWithSource(true)
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, `id="source-block"`) {
t.Errorf("ответ без свежего #source-block: %s", body)
}
if !strings.Contains(body, `id="action-bar"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
t.Errorf("ответ без oob-панели действий: %s", body)
}
if !strings.Contains(body, "/apply") {
t.Errorf("HasLinks=true, но в панели нет кнопки «Применить»: %s", body)
}
})
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)
}
body := rr.Body.String()
if !strings.Contains(body, `id="action-bar"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
t.Errorf("ответ без oob-панели действий: %s", body)
}
if strings.Contains(body, "/apply") {
t.Errorf("HasLinks=false, но в панели осталась кнопка «Применить»: %s", body)
}
})
}
// 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}}}
@@ -279,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)
}
}
}
+43 -7
View File
@@ -4,8 +4,9 @@ import (
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"time"
"git.vakhrushev.me/av/jellybit/internal/naming"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
) )
@@ -20,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
@@ -37,6 +39,7 @@ type downloadDetailView struct {
OriginalTitle string OriginalTitle string
Season string // сводка сезонов для сериала (пусто для фильма) Season string // сводка сезонов для сериала (пусто для фильма)
Year int Year int
Director string // режиссёр эффективного источника (пусто — неизвестен)
Provider string Provider string
ProviderID string ProviderID string
MatchURL string // ссылка на запись метабазы (пусто — показываем текстом) MatchURL string // ссылка на запись метабазы (пусто — показываем текстом)
@@ -47,12 +50,23 @@ type downloadDetailView struct {
// Живая статистика раздачи (заполняется из снимка воркера). // Живая статистика раздачи (заполняется из снимка воркера).
Seeding seedingView Seeding seedingView
// Nameable — доступно ручное обновление имени: есть распознанное название,
// которое можно перелить в display_name/ярлык раздачи. Гейтится наличием
// распознавания (в т.ч. на done/orphaned), НЕ состоянием ревью.
Nameable bool
// Действия по состоянию (как на главной). // Действия по состоянию (как на главной).
Terminal bool Terminal bool
Reviewable bool Reviewable bool
Undoable bool Undoable bool
Relinkable bool Relinkable bool
Retriable bool Retriable bool
Deletable bool // полное удаление доступно (done/orphaned/target_missing)
// Dismissable — доступен стоп-кран «Закрыть» (перевод в cancelled без
// действий над файлами/раздачей). Показываем в danger-зоне для терминальных,
// кроме deleted (строго терминален) и cancelled (уже закрыта, no-op); у
// не-терминальных ту же роль играет обычная «Отменить» — не дублируем.
Dismissable bool
} }
// detailTitle — заголовок страницы просмотра: имя раздачи (display_name) → // detailTitle — заголовок страницы просмотра: имя раздачи (display_name) →
@@ -76,11 +90,20 @@ 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
} }
s.deps.Logger.Error("download detail data", "id", id, "error", err) s.deps.Logger.Error("download detail data failed", "download_id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError) http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return return
} }
@@ -100,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,
@@ -111,12 +137,15 @@ 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.CanDelete(),
Dismissable: d.State.IsTerminal() &&
d.State != store.StateDeleted && d.State != store.StateCancelled,
} }
// Дата добавления рядом с шапкой (source_added_at → фолбэк created_at, // Дата добавления рядом с шапкой (source_added_at → фолбэк created_at,
// как в порядке и карточках списка); неразбираемое время просто опускаем. // как в порядке и карточках списка); неразбираемое время просто опускаем.
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 {
@@ -126,16 +155,23 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
view.RecTitle = rd.Plan.Title view.RecTitle = rd.Plan.Title
view.OriginalTitle = rd.Plan.OriginalTitle view.OriginalTitle = rd.Plan.OriginalTitle
if view.IsSeries { if view.IsSeries {
view.Season = seasonSummary(rd.Plan) view.Season = recognize.SeasonSummary(rd.Plan)
} }
view.Year = rd.Plan.Year view.Year = rd.Plan.Year
// Режиссёр — из того же слоистого разрешения, что и display_name в шапке
// (override → распознавание+матч → контекст), чтобы поле не расходилось с
// заголовком; пустой во всех слоях → прочерк в шаблоне.
view.Director = naming.EffectiveFields(d.ParsedContext, rd.Plan).Director
// Ручное обновление имени доступно, когда есть распознанное название,
// которое можно перелить (иначе FormatTitleYear даст пусто → no-op).
view.Nameable = rd.Plan.Title != ""
switch rd.Provider { switch rd.Provider {
case "", "none": case "", "none":
view.NoBase = rd.Provider == "none" view.NoBase = rd.Provider == "none"
default: default:
view.Provider = rd.Provider view.Provider = rd.Provider
view.ProviderID = rd.ProviderID view.ProviderID = rd.ProviderID
view.MatchURL = matchURL(rd, view.MediaType) view.MatchURL = rd.MatchURL()
} }
if rd.Recognition.Confidence.Valid { if rd.Recognition.Confidence.Valid {
view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64) view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64)

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