Compare commits

...
35 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
220 changed files with 15171 additions and 927 deletions
+3 -2
View File
@@ -2,8 +2,9 @@
"enabledPlugins": { "enabledPlugins": {
"frontend-design@claude-plugins-official": true, "frontend-design@claude-plugins-official": true,
"av-dev-git@av-dev-skills": true, "av-dev-git@av-dev-skills": true,
"av-dev-pm@av-dev-skills": true, "av-dev-docs@av-dev-skills": true,
"av-dev-pipeline@av-dev-skills": true "av-dev-tasks@av-dev-skills": true,
"av-dev-code@av-dev-skills": true
}, },
"extraKnownMarketplaces": { "extraKnownMarketplaces": {
"av-dev-skills": { "av-dev-skills": {
+73 -32
View File
@@ -22,7 +22,9 @@ Driven Development** через OpenSpec — см. раздел ниже.
Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module path — Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module path —
`git.vakhrushev.me/av/jellybit`. SQLite через `modernc.org/sqlite` + `sqlx`, `git.vakhrushev.me/av/jellybit`. SQLite через `modernc.org/sqlite` + `sqlx`,
миграции `goose`, HTTP — `chi` + `html/template` + htmx, конфиг — миграции `goose`, HTTP — `chi` + `html/template` + htmx, конфиг —
`pelletier/go-toml/v2`, логи — `log/slog` (структурированный JSON). `pelletier/go-toml/v2`, разбор `.torrent` и инфохэшей — `anacrolix/torrent`,
пред-парс имени раздачи — `middelink/go-parse-torrent-name`, логи — `log/slog`
(структурированный JSON).
## Инварианты ## Инварианты
@@ -32,6 +34,22 @@ Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module
- **Источник неприкосновенен** — под `paths.downloads` допустимы только чтение - **Источник неприкосновенен** — под `paths.downloads` допустимы только чтение
и `link(2)`; никаких `unlink`, `rename`, записи. Нарушение уничтожает и `link(2)`; никаких `unlink`, `rename`, записи. Нарушение уничтожает
невосстановимые данные пользователя. **Необратимо. `critical`.** невосстановимые данные пользователя. **Необратимо. `critical`.**
Исключения два, и оба — не наши операции с файловой системой, а вызов
`torrents/delete` qBittorrent с `deleteFiles=true`: (1) `Delete` из
`done`/`orphaned`/`target_missing` по явному подтверждению человека — гард
последней копии там выключен сознательно; подтверждение допустимо **одно на
пачку**, если называет каждую загрузку поимённо, и условия допуска групповой
путь не смягчает
([state-reconciliation](openspec/specs/state-reconciliation/spec.md),
[web-ui](openspec/specs/web-ui/spec.md));
(2) уборка воркером **собственного** торрента, добавленного этим же `add`
секундами ранее, когда закрытие **любым** путём (`Cancel` или `Dismiss`)
увело задачу из `catched` в окне после `add` — уборка привязана к состоянию,
а не к команде; признак «своё» даёт подтверждённое отсутствие инфохэша
непосредственно перед `add`
([download-tracking](openspec/specs/download-tracking/spec.md),
[state-reconciliation](openspec/specs/state-reconciliation/spec.md)). Всё
остальное под `paths.downloads` — по-прежнему `critical`.
- **Последняя копия не снимается** — `Undo` отклоняется целиком, если у цели не - **Последняя копия не снимается** — `Undo` отклоняется целиком, если у цели не
осталось других жёстких ссылок (`nlink <= 1`) или исходного файла уже нет. осталось других жёстких ссылок (`nlink <= 1`) или исходного файла уже нет.
Частичный откат тоже стёр бы часть данных. **Необратимо. `critical`.** Частичный откат тоже стёр бы часть данных. **Необратимо. `critical`.**
@@ -47,14 +65,24 @@ Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module
qBittorrent, ключи LLM и метабаз, токен Telegram, API-ключ Jellyfin. qBittorrent, ключи LLM и метабаз, токен Telegram, API-ключ Jellyfin.
Утёкший в лог секрет отзывается вручную. **`major`.** Утёкший в лог секрет отзывается вручную. **`major`.**
- **Авто-раскладка только при подтверждённом матче в метабазе** — самооценка - **Авто-раскладка только при подтверждённом матче в метабазе** — самооценка
LLM гейтом не является LLM **единственным** гейтом не является и матч не заменяет: порог
([ADR](docs/adr/ADR-2026-06-13-auto-link-requires-db-match.md)). Обратимо `[recognition].auto_confidence_threshold` стоит поверх матча дополнительным
условием
([ADR](docs/adr/ADR-2026-06-13-auto-link-requires-db-match.md),
[recognition](openspec/specs/recognition/spec.md)). Обратимо
через `Undo`. **`major`.** через `Undo`. **`major`.**
- **Не более одной активной загрузки на infohash** — проверка отсутствия другой
активной загрузки и вставка идут одной write-транзакцией (`_txlock=immediate`,
guarded-методы `store`); обход даёт две задачи, претендующие на одну раздачу и
один целевой путь. Обратимо (лишняя закрывается), но состояние расходится.
**`major`.** Поведение — [ingest](openspec/specs/ingest/spec.md).
- **Переходы состояний — только через `worker` под per-download блокировкой**, - **Переходы состояний — только через `worker` под per-download блокировкой**,
и только легальные по декларативному графу. Обход даёт гонку двух и только легальные по декларативному графу. Обход даёт гонку двух
транспортов. **`major`.** транспортов. **`major`.**
- **Время — только `store.Now()` (UTC), идентификаторы — только `ident`**; - **Время — только `store.Now()` (UTC), идентификаторы — только `ident`**;
`ident.Parse` на каждой входной границе. Механизировано линтером. **`minor`.** `ident.Parse` на каждой входной границе. Время механизировано линтером
(`forbidigo` на `time.Now`); правило про `ident` линтером не проверяется —
держится на ревью. **`minor`.**
## Команды ## Команды
@@ -81,17 +109,23 @@ Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module
- **Что красит безусловно:** сборка, `go vet`, `golangci-lint`, `gofmt`, тесты, - **Что красит безусловно:** сборка, `go vet`, `golangci-lint`, `gofmt`, тесты,
флаки-прогон (второй прогон разошёлся с первым), `-race`, накат миграций с флаки-прогон (второй прогон разошёлся с первым), `-race`, накат миграций с
нуля, `gitleaks`, канон документации (`docs.py check` — раскладка `docs/`, нуля, `gitleaks`, канон документации (`docs.py check` — раскладка `docs/`,
битые ссылки, «миграция изменена, а `database.md` нет»). Причина одна: у битые ссылки, «миграция изменена, а `database.md` нет»), каталог задач
каждого из них есть объективный оракул, спорить не о чем. (`tasks.py check --dir tasks` — согласованность `tasks/BACKLOG.md` и
`tasks/items/`), форма конфига OpenSpec (`openspec.py check` — незаменённый
пример в `openspec/config.yaml`). Причина одна: у каждого из них есть
объективный оракул, спорить не о чем. Каждый из трёх последних краснеет и
когда своего скрипта нет: молча пропущенная проверка неотличима от пройденной.
- **Чего в гейте намеренно нет и кто обязан это гонять:** - **Чего в гейте намеренно нет и кто обязан это гонять:**
- `govulncheck` даёт `WARN`, а не `FAIL`: находка тут — состояние - `govulncheck` даёт `WARN`, а не `FAIL`: находка тут — состояние
зависимостей, а не диффа. Разбирает агент ревью по трассам вызовов. зависимостей, а не диффа. Разбирает агент ревью по трассам вызовов.
- `-race` без gcc уходит в `SKIP` с явным «гонки НЕ проверены» — тогда их - `-race` без gcc уходит в `SKIP` с явным «гонки НЕ проверены» — тогда их
проверяет проход `ops` рассуждением, и это идёт в границы покрытия. проверяет рассуждением тема `operations` ([docs/review.md](docs/review.md)
→ «Вопросы по темам»), и это идёт в границы покрытия.
- Ничего не гоняется против **живого** qBittorrent, LLM и метабаз: - Ничего не гоняется против **живого** qBittorrent, LLM и метабаз:
интеграционные тесты за env-гейтами, запускает человек вручную. интеграционные тесты за env-гейтами, запускает человек вручную.
- Качество распознавания гейтом не проверяется вовсе — нужен корпус кейсов - Качество распознавания гейтом не проверяется вовсе и проверяться не будет:
(задача `recognition-eval-harness`). размеченный корпус решено не собирать (`tasks/REJECTED.md`,
2026-08-06). Сдвиг точности виден только по рабочему потоку.
## Запреты ## Запреты
@@ -117,12 +151,19 @@ Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module
(`git merge-base HEAD master`), в неё вливает батч, от неё ветвятся задачи. (`git merge-base HEAD master`), в неё вливает батч, от неё ветвятся задачи.
- **Необратимое** (спрашивается у человека всегда): всё, что пишет в - **Необратимое** (спрашивается у человека всегда): всё, что пишет в
`paths.downloads` или удаляет оттуда; удаление раздачи из qBittorrent вместе `paths.downloads` или удаляет оттуда; удаление раздачи из qBittorrent вместе
с файлами (`Delete`); снятие последней копии данных; правка уже применённой с файлами (`Delete`) — кроме уборки собственного, только что добавленного
миграции; `git push --force`; удаление или перезапись файла в библиотеке торрента, когда закрытие любым путём увело задачу из `catched` (см.
Jellyfin, которого мы не создавали. исключения инварианта выше); снятие последней копии
- **Общий станок** — покрасневший `task gate` на `master` врывается в данных; правка уже применённой миграции; `git push --force`; удаление или
замороженный спринт: пока он красный, ни одна задача не считается сделанной. перезапись файла в библиотеке Jellyfin, которого мы не создавали.
- **Ориентир по размеру спринта:** 5–8 задач. Ориентир, а не закон. - **Что считается сломанным:** покрасневший `task gate` на `master`. Пока он
красный, ни одна задача не считается сделанной, и чинится он раньше любой
другой работы: гейт один на все задачи.
- **Приоритет — это порядок строк в [tasks/BACKLOG.md](tasks/BACKLOG.md).**
Первая строка секции — то, что делают следующим. Порядок назначает человек на
груминге (`av-dev-tasks:groom`), машина его не выводит.
- **Ориентир по размеру порции разбора на груминге:** 5–8 задач. Ориентир, а не
закон.
- **Что такое «сделана»:** пайплайн задачи пройден целиком (спека → код → оба - **Что такое «сделана»:** пайплайн задачи пройден целиком (спека → код → оба
чекпоинта ревью → archive) и критерии приёмки проверены поимённо. чекпоинта ревью → archive) и критерии приёмки проверены поимённо.
@@ -132,30 +173,30 @@ Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module
(CLI `openspec`, v1.x). Сначала спецификация — потом код. (CLI `openspec`, v1.x). Сначала спецификация — потом код.
- `openspec/specs/<capability>/spec.md`**нормативный дом поведения**: что - `openspec/specs/<capability>/spec.md`**нормативный дом поведения**: что
система делает сейчас. Capability — это поведение или домен (`ingest`, система делает сейчас. Capability — это поведение или домен системы, а не
`recognition`, `file-layout`, `review`, `notifications`), а не пакет кода. пакет кода.
- `openspec/changes/<id>/` — предлагаемое изменение: `proposal.md`, `design.md` - `openspec/changes/<id>/` — предлагаемое изменение: `proposal.md`, `design.md`
(для нетривиальных), дельта-спеки (`ADDED`/`MODIFIED`/`REMOVED Requirements`), (для нетривиальных), дельта-спеки (`ADDED`/`MODIFIED`/`REMOVED Requirements`),
`tasks.md`. После реализации change архивируется в `tasks.md`. После реализации change архивируется в
`openspec/changes/archive/`, дельты вливаются в `openspec/specs/`. `openspec/changes/archive/`, дельты вливаются в `openspec/specs/`.
- `openspec/config.yaml` — только нужды генерации артефактов: язык, правила - `openspec/config.yaml` — только нужды генерации артефактов: язык, правила
именования capability, придирки валидатора. именования capability, придирки валидатора — плюс адреса документов канона.
Пересказа этих документов там нет: второй дом факта расходится молча.
Поток работы — через слэш-команды `opsx:*`: `opsx:explore` (продумать), Поток работы — через слэш-команды `opsx:*`: `opsx:explore` (продумать),
`opsx:propose` (завести change), `opsx:apply` (реализовать tasks), `opsx:propose` (завести change), `opsx:apply` (реализовать tasks),
`opsx:sync`/`opsx:archive` (влить и архивировать). `opsx:sync`/`opsx:archive` (влить и архивировать).
Правила спек: Правила спек — язык, именование capability и придирки валидатора — живут в
[openspec/config.yaml](openspec/config.yaml) (`context` и `rules`), оттуда их
читает порождение артефактов; здесь не дублируются. Перед коммитом change —
`openspec validate --strict`.
- Каждое `### Requirement` ОБЯЗАНО содержать литерал `SHALL` или `MUST` Ревью — два чекпоинта: ревью дизайна на предложении (после design/specs, ДО
иначе `openspec validate` падает. кода) и ревью изменения после apply, до archive. Состав обоих выбирается по
- Структурные заголовки и ключевые слова — английские (`### Requirement:`, метке задачи (`small` / `medium` / `large`), которую разметка ставит один раз
`#### Scenario:`, `GIVEN/WHEN/THEN`, RFC 2119), остальной текст — русский. после propose. Настройка конвейера под проект и журнал дефектов —
- `openspec validate --strict` перед коммитом change. [docs/review.md](docs/review.md).
Ревью — два чекпоинта: профиль `design` на предложении (после design/specs, ДО
кода) и ревью изменения после apply, до archive. Настройка конвейера под проект
и журнал дефектов — [docs/review.md](docs/review.md).
## Документация ## Документация
@@ -172,12 +213,12 @@ Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module
- [docs/research/](docs/research/README.md) — наблюдения за чужими форматами. - [docs/research/](docs/research/README.md) — наблюдения за чужими форматами.
- [docs/adr/](docs/adr/README.md) — журнал решений, неизменяемый. - [docs/adr/](docs/adr/README.md) — журнал решений, неизменяемый.
- [docs/review.md](docs/review.md) — настройка конвейера ревью и журнал дефектов. - [docs/review.md](docs/review.md) — настройка конвейера ревью и журнал дефектов.
- [docs/tasks/](docs/tasks/BACKLOG.md) — задачи и цели: одна запись = один файл - [tasks/](tasks/BACKLOG.md) — задачи и цели: одна запись = один файл
в `items/` + строка в индексе. Ведётся скиллом `av-dev-pm:tasks`, ритуал в `items/` + строка в индексе, порядок строк = приоритет. Ведётся скиллом
спринта`av-dev-pm:session`. `av-dev-tasks:tasks`, разбор беклога`av-dev-tasks:groom`.
**Tududi** (проект `jellybit`, project_id 14) — только инбокс сырых идей. Идея **Tududi** (проект `jellybit`, project_id 14) — только инбокс сырых идей. Идея
становится задачей, когда её оформляют файлом в `docs/tasks/items/`. становится задачей, когда её оформляют файлом в `tasks/items/`.
## Конвенции кода ## Конвенции кода
+14 -21
View File
@@ -35,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`-файл; фетч `.torrent` по обычной
ссылке — в планах. Что дальше — [docs/tasks/PLAN.md](docs/tasks/PLAN.md).
## Документация ## Документация
@@ -64,7 +60,7 @@ REST API, веб-UI, Telegram-бот и CLI (`jellybit add`).
[веб-UI](docs/conventions/web-ui.md). [веб-UI](docs/conventions/web-ui.md).
- [docs/adr/](docs/adr/README.md) — журнал решений (почему так), неизменяемый. - [docs/adr/](docs/adr/README.md) — журнал решений (почему так), неизменяемый.
- [docs/research/](docs/research/README.md) — наблюдения за чужими форматами. - [docs/research/](docs/research/README.md) — наблюдения за чужими форматами.
- [docs/tasks/](docs/tasks/BACKLOG.md) — задачи и цели. - [tasks/](tasks/BACKLOG.md) — задачи и цели.
Раскладка документации задана каноном av-dev и проверяется шагом `canon` в Раскладка документации задана каноном av-dev и проверяется шагом `canon` в
`task gate`. `task gate`.
@@ -73,7 +69,8 @@ REST API, веб-UI, Telegram-бот и CLI (`jellybit add`).
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`). Полный перечень с версиями —
[CLAUDE.md](CLAUDE.md) → «Стек»; как эти компоненты сложены —
[docs/architecture.md](docs/architecture.md). [docs/architecture.md](docs/architecture.md).
## Конфигурация ## Конфигурация
@@ -118,16 +115,12 @@ jellybit recognize <infohash> --dry-run [--context "..."] --config ./config.toml
## Доставка ## Доставка
Рассчитан на домашний медиа-сервер. Артефакты репозитория — статический Рассчитан на домашний медиа-сервер. Артефакты репозитория — статический бинарь
бинарь (`task build`) и `Dockerfile` (упаковка в `distroless/static`). Образ (`task build`) и `Dockerfile`; образ собирается целиком локально на
собирается целиком **локально** на control-хосте (`task image`) и едет на control-хосте (`task image`) и едет на сервер через `docker save`/`load`.
сервер через `docker save`/`load` (роль `app_image` в umbar), поэтому
Go-тулчейн и `docker build` на сервере не нужны. В 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) → «Деплой».
+1
View File
@@ -278,6 +278,7 @@ func metadataProviders(cfg *config.Config, logger *slog.Logger) ([]metadata.Prov
APIKey: cfg.Metadata.TVDB.APIKey, APIKey: cfg.Metadata.TVDB.APIKey,
Proxy: cfg.Metadata.TVDB.Proxy, Proxy: cfg.Metadata.TVDB.Proxy,
Timeout: cfg.Metadata.TVDB.Timeout.Std(), Timeout: cfg.Metadata.TVDB.Timeout.Std(),
Language: cfg.ContentLanguage(),
}, logger) }, logger)
if err != nil { if err != nil {
return nil, fmt.Errorf("tvdb provider: %w", err) return nil, fmt.Errorf("tvdb provider: %w", err)
+1 -1
View File
@@ -49,7 +49,7 @@ timeout = "10s" # таймаут запроса к TMDB; Go
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)
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"canon": 2, "canon": 12,
"migrations": "internal/store/migrations" "migrations": "internal/store/migrations"
} }
@@ -1,6 +1,9 @@
# Авто-раскладка только при подтверждённом матче в метабазе # Авто-раскладка только при подтверждённом матче в метабазе
- **Дата:** 2026-06-13 - **Дата:** 2026-06-13
- **Источник:** архивного `design.md` нет — решение принято до перехода
проекта на OpenSpec (первый архивный change — `2026-06-28`); первичный
материал — история git
## Контекст ## Контекст
+3
View File
@@ -1,6 +1,9 @@
# Docker как единица деплоя, образ собирается на сервере # Docker как единица деплоя, образ собирается на сервере
- **Дата:** 2026-06-13 - **Дата:** 2026-06-13
- **Источник:** архивного `design.md` нет — решение принято до перехода
проекта на OpenSpec (первый архивный change — `2026-06-28`); первичный
материал — история git
- **Статус:** заменено на ADR-2026-07-24-local-image-build - **Статус:** заменено на ADR-2026-07-24-local-image-build
## Контекст ## Контекст
@@ -1,6 +1,9 @@
# Go и доставка одним бинарём # Go и доставка одним бинарём
- **Дата:** 2026-06-13 - **Дата:** 2026-06-13
- **Источник:** архивного `design.md` нет — решение принято до перехода
проекта на OpenSpec (первый архивный change — `2026-06-28`); первичный
материал — история git
## Контекст ## Контекст
+3
View File
@@ -1,6 +1,9 @@
# Хардлинки вместо копирования и симлинков # Хардлинки вместо копирования и симлинков
- **Дата:** 2026-06-13 - **Дата:** 2026-06-13
- **Источник:** архивного `design.md` нет — решение принято до перехода
проекта на OpenSpec (первый архивный change — `2026-06-28`); первичный
материал — история git
## Контекст ## Контекст
@@ -1,6 +1,8 @@
# Конвейер ревью: гейт, generative-проходы и обязательный триаж # Конвейер ревью: гейт, generative-проходы и обязательный триаж
- **Дата:** 2026-07-23 - **Дата:** 2026-07-23
- **Источник:** архивного `design.md` нет — решение процессное, change'ем jellybit
не велось
## Контекст ## Контекст
@@ -1,6 +1,8 @@
# Образ собирается локально и едет на сервер через docker save/load # Образ собирается локально и едет на сервер через docker save/load
- **Дата:** 2026-07-24 - **Дата:** 2026-07-24
- **Источник:** архивного `design.md` нет — решение деплойное, change'ем jellybit
не велось
## Контекст ## Контекст
@@ -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` — общем доме набора закреплённых значений, через который
идут и предпросмотр, и закрепление.
+7 -1
View File
@@ -11,7 +11,7 @@
Верно одно из трёх: Верно одно из трёх:
<!-- копия: adr-когда-заводить из av-dev-pm/skills/canon/references/canon.md --> <!-- копия: adr-когда-заводить из av-dev-docs/skills/canon/references/canon.md -->
- **дорогой откат** — переделка стоит дороже переписывания одного файла; - **дорогой откат** — переделка стоит дороже переписывания одного файла;
- **намеренный отказ** от очевидного подхода; - **намеренный отказ** от очевидного подхода;
- **пересмотр прежнего решения** — тогда у старой записи обязателен статус - **пересмотр прежнего решения** — тогда у старой записи обязателен статус
@@ -42,6 +42,12 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| --- | --- | --- | | --- | --- | --- |
| 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-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-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-23 | [Конвейер ревью: гейт, generative-проходы и триаж](ADR-2026-07-23-review-pipeline-generative.md) | — |
+41 -16
View File
@@ -48,21 +48,27 @@
| `archrules` | собственный анализатор архитектурных правил (часть гейта) | — | | `archrules` | собственный анализатор архитектурных правил (часть гейта) | — |
Транспорты приёма — HTTP API, веб-UI, Telegram и CLI (`jellybit add`) — ведут в Транспорты приёма — HTTP API, веб-UI, Telegram и CLI (`jellybit add`) — ведут в
один `ingest`; действия пользователя (apply / refine / reject / defer / undo / один `ingest`; действия пользователя идут командами к `worker`. Перечень команд
retry / delete / dismiss) идут командами к `worker`. и их эффекты — нормативно в [review](../openspec/specs/review/spec.md), пути
закрытия и удаления — в
[state-reconciliation](../openspec/specs/state-reconciliation/spec.md).
## Внешние границы и форматы ## Внешние границы и форматы
- **qBittorrent WebUI API** — единственный способ качать: источник (magnet, URL, - **qBittorrent WebUI API** — единственный способ качать: источник **отдаём
`.torrent`) **отдаём ему**, сами по пользовательскому URL не ходим (SSRF ему**, сами по пользовательскому URL не ходим (SSRF исключён). Какие виды
исключён). Пути берём из API (`save_path` + относительные имена из источника принимаются и как разбираются —
`/torrents/files`), не из константы. [ingest](../openspec/specs/ingest/spec.md); способ добавления по
- **LLM** — OpenAI-совместимый Chat Completions (`[llm].type = "openai-compat"`), `source_type` — [download-tracking](../openspec/specs/download-tracking/spec.md);
структурированный вывод через `response_format: json_object`; валидация ответа откуда берутся пути файлов —
своя, в Go. [file-layout](../openspec/specs/file-layout/spec.md).
- **LLM** — OpenAI-совместимый Chat Completions за интерфейсом (`[llm].type`);
контракт вызова, формат вывода и разбор ответа —
[recognition](../openspec/specs/recognition/spec.md).
- **Метабазы** — TMDB, TVDB, TVMaze (последняя без ключа, только сериалы). - **Метабазы** — TMDB, TVDB, TVMaze (последняя без ключа, только сериалы).
- **Jellyfin** — один вызов `POST /Library/Refresh`, авторизация заголовком - **Jellyfin** — HTTP-триггер пересканирования медиатеки, опционален; когда он
`X-Emby-Token`. дёргается и по каким переходам —
[file-layout](../openspec/specs/file-layout/spec.md).
- **Telegram Bot API** — приём сообщений и исходящие карточки/пинги. - **Telegram Bot API** — приём сообщений и исходящие карточки/пинги.
- **Сообщение торрент-бота** — чужой текстовый формат, разбирается парсером - **Сообщение торрент-бота** — чужой текстовый формат, разбирается парсером
`tgbot`; наблюдения по формату — в `tgbot`; наблюдения по формату — в
@@ -90,8 +96,9 @@ retry / delete / dismiss) идут командами к `worker`.
задаче, застрявшей в промежуточном состоянии; логи в stdout контейнера. задаче, застрявшей в промежуточном состоянии; логи в stdout контейнера.
Автоматического алертинга нет, метрик нет — только уведомления в Telegram о Автоматического алертинга нет, метрик нет — только уведомления в Telegram о
падении загрузки и о рассинхроне. падении загрузки и о рассинхроне.
- **Характер потока:** непрерывный фон (тик поллинга qBittorrent, по умолчанию - **Характер потока:** непрерывный фон (тик поллинга qBittorrent — период в
5 с, и периодическая сверка) плюс редкие события по запросу человека. Объём — [database.md](database.md) → «Настройки с числовым значением» — и
периодическая сверка) плюс редкие события по запросу человека. Объём —
единицы загрузок в день, десятки одновременно; ориентир масштаба и его аудит — единицы загрузок в день, десятки одновременно; ориентир масштаба и его аудит —
задача в беклоге. задача в беклоге.
@@ -101,13 +108,18 @@ retry / delete / dismiss) идут командами к `worker`.
| Что | Где | | Что | Где |
| --- | --- | | --- | --- |
| Время | `store.Now()` — единственный источник, всегда UTC; формат хранения — RFC 3339 | | Время | `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/ident` — генерация и нормализация ULID; `ident.Parse` на каждой входной границе |
| Целевые имена и превью раскладки | `internal/naming` — одна логика для превью в UI и для реального применения | | Целевые имена и превью раскладки | `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`; инфохэш извлекается только здесь | | Разбор источника | `internal/magnet` и `internal/torrent`; инфохэш извлекается только здесь |
| Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI | | Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI |
| Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом | | Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом |
| Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки | | Хардлинки и удаление своих ссылок | `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) | | Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) |
| Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) | | Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) |
| Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям | | Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям |
@@ -165,5 +177,18 @@ Jellyfin указывают на `movies`/`series`, а не на корень
## Открытые вопросы ## Открытые вопросы
- Пока нет. Решённое разъехалось по ADR и capability-спекам; то, что требует Места, где устройство знаемо тонкое: не дефекты, а принятые пока пробелы. Здесь
работы, живёт задачами в [tasks/BACKLOG.md](tasks/BACKLOG.md). только адрес и одна фраза — что именно не сделано; работа под каждым живёт
задачей в [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 | лимита на размер ответа нет — единственный недоверенный канал без предела; задачей пока не заведено | — |
+12 -8
View File
@@ -6,9 +6,6 @@
Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел
«Конвенции кода». «Конвенции кода».
> Каркас. Загрузчик `internal/config/config.go` уже грузит TOML; валидация
> на старте — в работе (`TODO`), обкатывается на следующем шаге.
## Принципы ## Принципы
- **Конфигурация — только TOML.** Env-переменные для конфига **не - **Конфигурация — только TOML.** Env-переменные для конфига **не
@@ -40,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` — источник истины по
составу полей.
Секретные поля оставляем пустыми — значение приходит из деплоя (см. Секретные поля оставляем пустыми — значение приходит из деплоя (см.
«Секреты»). «Секреты»).
+1 -1
View File
@@ -2,7 +2,7 @@
Как мы устраиваем таблицы и ключи в SQLite. Актуальная схема — Как мы устраиваем таблицы и ключи в SQLite. Актуальная схема —
[../database.md](../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'))` в новых **Механизировано:** `AUTOINCREMENT` и `DEFAULT (datetime('now'))` в новых
миграциях (`internal/archrules`), время мимо `store.Now()` (`forbidigo`). миграциях (`internal/archrules`), время мимо `store.Now()` (`forbidigo`).
+22 -3
View File
@@ -66,7 +66,15 @@ jellybit — **приложение, а не библиотека**: внешн
- **+ корреляционный ключ** для владельца — `download_id` (если операция - **+ корреляционный ключ** для владельца — `download_id` (если операция
к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах. к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах.
Пример: «При обработке загрузки произошла ошибка, download_id=12345», а Пример: «При обработке загрузки произошла ошибка, download_id=12345», а
не «произошла ошибка» и не сырой текст; не «произошла ошибка» и не сырой текст.
**Ключ есть не у всякого транспорта, и это называется вслух.** `request_id`
— понятие HTTP-границы (chi `RequestID`); у Telegram и CLI его нет. Если
операция ещё не завела загрузку (отказ приёма), у такого транспорта ключа
нет вовсе — тогда сообщение остаётся без якоря, а диагностика ищется по
записи доменной границы (`capability`, `infohash`). Заводить транспорту
собственный идентификатор запроса ради ключа — решение уровня спеки, а не
умолчание: второй канал корреляции рядом с существующим дороже, чем
отсутствие ключа;
- **маппинг доменной ошибки → статус/сообщение** (в jellybit — - **маппинг доменной ошибки → статус/сообщение** (в jellybit —
`httpapi.classifyErr`, единая точка для REST и веб-UI): `httpapi.classifyErr`, единая точка для REST и веб-UI):
@@ -74,9 +82,14 @@ jellybit — **приложение, а не библиотека**: внешн
|---|---|---| |---|---|---|
| `store.ErrNotFound` | 404 | «не найдено» | | `store.ErrNotFound` | 404 | «не найдено» |
| `magnet.ErrNotMagnet` / `torrent.ErrNotTorrent` | 400 | «некорректный источник» | | `magnet.ErrNotMagnet` / `torrent.ErrNotTorrent` | 400 | «некорректный источник» |
| `ingest.ErrTorrentTooLarge` (файл больше лимита) | 400 | «файл .torrent слишком большой» |
| `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» | | `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» |
| `errManualSource` (ручной ввод источника, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
| `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
| `errBatchEmpty` / `errBatchTooLarge` / `errBatchBadID` (разбор пачки группового удаления, локальные sentinel'ы `httpapi`) | 400 | текст самой ошибки |
| `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» | | `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» |
| `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» | | `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» |
| `layout.ErrNameTooLong` (целевое имя не помещается, ушло в review) | 409 | «целевое имя слишком длинное…» |
| `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» | | `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» |
| прочее | 500 | «внутренняя ошибка» | | прочее | 500 | «внутренняя ошибка» |
@@ -97,7 +110,7 @@ jellybit — **приложение, а не библиотека**: внешн
в review/failed: коллизия, рассинхрон, сбой ФС) и `reasons` распознавания, в review/failed: коллизия, рассинхрон, сбой ФС) и `reasons` распознавания,
сохранённые в БД и показываемые на экране ревью и в Telegram-карточке. Это сохранённые в БД и показываемые на экране ревью и в Telegram-карточке. Это
**операторская поверхность владельца**: сервис однопользовательский в **операторская поверхность владельца**: сервис однопользовательский в
доверенной LAN (см. [architecture.md](../architecture.md)), эти поля — доверенном контуре (см. [security.md](../security.md) → «Периметр»), эти поля —
диагностический контекст для того, кто разбирает задачу. Здесь сырой текст диагностический контекст для того, кто разбирает задачу. Здесь сырой текст
ошибки (пути, фрагмент ответа LLM/qBittorrent) **допустим и полезен** — но: ошибки (пути, фрагмент ответа LLM/qBittorrent) **допустим и полезен** — но:
- **секреты запрещены** абсолютно (токены/ключи/пароли/`Authorization`) — так - **секреты запрещены** абсолютно (токены/ключи/пароли/`Authorization`) — так
@@ -105,7 +118,13 @@ jellybit — **приложение, а не библиотека**: внешн
error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок
транспорта, несущих URL с секретом); транспорта, несущих URL с секретом);
- это **не** канал для транзиентных отказов команд — те остаются нейтральными - это **не** канал для транзиентных отказов команд — те остаются нейтральными
(см. выше). (см. выше);
- **внешнее значение в тексте усекается на границе, а его размер называется
числом.** `error_msg` уезжает в баннер ревью, в панель действий и в карточку
Telegram; имя файла на 400 байт занимает там экран целиком и оседает в БД
навсегда. Усечение — серединой и по рунам (`layout.shorten`,
`naming.truncate`, `tgbot.shorten`), точная величина остаётся числом рядом:
без неё человек не поймёт, насколько сокращать.
## panic ## panic
+9 -3
View File
@@ -74,9 +74,10 @@ OpenSpec-спеках (`### Requirement` с `SHALL`).
- Поле — `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); бизнес-логика в локальной зоне не работает.
## Поля: словарь имён ## Поля: словарь имён
@@ -203,6 +204,11 @@ Go-ошибки логируем как атрибут, не как текст
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`, - Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
`http.status_code`, `duration_ms`, `transport` (`http`/`web`/`telegram`). `http.status_code`, `duration_ms`, `transport` (`http`/`web`/`telegram`).
- **Поле, которое уже даёт scoped-логгер, руками не доклеиваем.** Команда,
положившая scoped-логгер в `ctx`, не передаёт `download_id` ещё и аргументом
записи: в JSON получается дублирующийся ключ, и строгий потребитель молча
оставит одно из значений. Правило следует из «логгер несёт ключи сам» и
проверяется чтением — линтером не выражается.
- Для корреляции HTTP-запроса допустим `request_id` (напр. chi `RequestID`) — - Для корреляции HTTP-запроса допустим `request_id` (напр. chi `RequestID`) —
это отдельный слой от корреляции загрузки по `download_id` и не противоречит это отдельный слой от корреляции загрузки по `download_id` и не противоречит
отказу от `trace_id`. Если запрос порождает загрузку — связь даёт отказу от `trace_id`. Если запрос порождает загрузку — связь даёт
+35 -14
View File
@@ -103,27 +103,48 @@ htmx по умолчанию **не свопит DOM на ответы 4xx/5xx**
## Живой поллинг ## Живой поллинг
Паттерн живого обновления: фрагмент-эндпоинт под `/fragments/...` + в разметке Паттерн живого обновления: фрагмент-эндпоинт под `/fragments/...` + в разметке
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`талон — `hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`. Эталон — карточка
`progress`/`seeding`, `handleFragProgress`/`handleFragSeeding`): списка (`card`, `handleFragCard`):
```html ```html
{{define "progress"}}<div id="dl-live-{{.ID}}" {{define "card"}}<article class="card" id="card-{{.ID}}"
{{if .Active}} hx-get="/fragments/downloads/{{.ID}}/progress" {{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card"
hx-trigger="every 3s" hx-swap="outerHTML"{{end}}> hx-trigger="every {{.PollEvery}}" hx-swap="outerHTML"{{end}}>
... ...
</div>{{end}} </article>{{end}}
``` ```
- **Поллер самозавершается.** Когда состояние выходит из «живого» (`Active` - **Один поллер на обновляемый корень.** Опрашивает себя корень поверхности
ложно, торрент не сидирует), фрагмент возвращается **без `hx-*`** — htmx (карточка списка, главная область страницы), а вложенные живые регионы —
больше не опрашивает. Условие «живости» ведёт store-состояние (`downloading` прогресс качания, секция раздачи — своего `hx-get` **не несут**: своп корня
для прогресса), а не qBittorrent. уносит их вместе с таймером, и два опроса подменяли бы разметку друг друга.
Живые цифры приезжают вместе с корнем.
- **Поллер самозавершается.** Опрос ведётся, пока предмет может измениться без
участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx
больше не опрашивает. Условие определяется store-состоянием
(`State.IsObservable()`), а не qBittorrent.
- **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает
`200` и фрагментом с объяснением **без `hx-*`**: htmx не свопит `4xx/5xx`,
поэтому статус ошибки оставил бы поверхность навсегда прежней, а опрос —
бесконечным. Фрагмент отказа обязан нести корневой `id` того узла, который он
собой заменяет (см. инвариант выше), иначе `hx-swap` подменит не тот узел.
- **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный ретрай;
`ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)).
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его - **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
поллером и htmx `process`-инициализирует новый — двойного опроса нет **при поллером и htmx `process`-инициализирует новый — двойного опроса нет **при
условии совпадения корневого `id`** (см. инвариант выше). условии совпадения корневого `id`** (см. инвариант выше). Эфемерное состояние
- Данные тика — из in-memory снимка воркера (`LiveStatus.Live(infohash)`), без разметки своп не переживает: то, что должно пережить тик (раскрытый
БД/сети на каждый тик; узкий контракт `LiveStatus` не зависит от способа `<details>`), помечается `hx-preserve`.
доставки (поллинг сейчас, путь к SSE оставлен изолированным). - **Частота — по цене тика, и она названа числом в
[database.md](../database.md).** Поверхность с живыми цифрами качания
обновляется чаще (`pollFast`, вровень с частотой опроса qBittorrent — быстрее
источника опрашивать бессмысленно), прочие наблюдаемые — реже (`pollSlow`).
- **Тик ходит в БД, и это цена решения.** Живые цифры берутся из in-memory
снимка воркера (`LiveStatus.Live(infohash)`), но состояние и размер раскладки
тик читает из хранилища, а тик страницы загрузки ещё и считает предпросмотр
раскладки с обходом ФС — отсюда и разные интервалы. Узкий контракт
`LiveStatus` при этом не зависит от способа доставки (поллинг сейчас, путь к
SSE оставлен изолированным).
- **Инвариант: браузер не опрашивает qBittorrent напрямую** — только свой сервер, - **Инвариант: браузер не опрашивает qBittorrent напрямую** — только свой сервер,
который читает снимок. Поллинг статуса UI логируем на `DEBUG` (рутинно-частое, который читает снимок. Поллинг статуса UI логируем на `DEBUG` (рутинно-частое,
см. [logging.md](logging.md)). см. [logging.md](logging.md)).
+14 -1
View File
@@ -207,5 +207,18 @@ erDiagram
| `[llm].timeout` / `max_retries` | `120s` / `3` | каждая попытка порождает строку `recognition` с сырым ответом | | `[llm].timeout` / `max_retries` | `120s` / `3` | каждая попытка порождает строку `recognition` с сырым ответом |
| `[metadata.*].timeout` | `10s` | таймаут запроса к метабазе | | `[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 нет, **Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет,
кэша метабаз нет — всё три пункта в беклоге. кэша метабаз нет; состояние по каждому пробелу и заведённые под них задачи —
[architecture.md](architecture.md) → «Открытые вопросы».
+9 -3
View File
@@ -1,7 +1,7 @@
# Паспорт проекта # Паспорт проекта
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
устроено», [tasks/PLAN.md](tasks/PLAN.md) — «в каком порядке», паспорт — устроено», [tasks/ROADMAP.md](../tasks/ROADMAP.md) — «что уже умеет», паспорт —
«зачем и для кого». «зачем и для кого».
## Цель ## Цель
@@ -44,8 +44,14 @@ Jellyfin, без ручного переименования и без ката
- **Не медиасервер.** Обложки, метаданные, учёт просмотренного и сам просмотр — - **Не медиасервер.** Обложки, метаданные, учёт просмотренного и сам просмотр —
забота Jellyfin. Мы отвечаем только за то, чтобы файл лежал там, где Jellyfin забота 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)). доступа сводится к allowlist Telegram (см. [security.md](security.md)).
+20 -6
View File
@@ -1,7 +1,13 @@
# Разведка # Разведка
Наблюдения за внешним миром: что реально шлёт источник, чем документация формата Наблюдения за внешним миром: что реально шлёт источник, чем документация формата
расходится с практикой. Источник истины — этот каталог, а не чужая документация. расходится с практикой и как ведут себя наши разборщики и зависимости на границе
формата. Источник истины — этот каталог, а не чужая документация.
Наблюдение о чужом **коде** живёт здесь наравне с наблюдением о чужих
**данных**, но у него есть срок годности: такая записка обязана называть версию
зависимости и условие пересмотра, потому что протухает от обновления `go.mod`, а
не от смены формата.
**Каждый вывод — с числами и командой или условиями, которыми получен**, чтобы **Каждый вывод — с числами и командой или условиями, которыми получен**, чтобы
его можно было перепроверить. Число без провенанса проход обязан читать как его можно было перепроверить. Число без провенанса проход обязан читать как
@@ -14,14 +20,22 @@
Наблюдения снимались вручную, по ходу разработки, на домашнем контуре: реальные Наблюдения снимались вручную, по ходу разработки, на домашнем контуре: реальные
сообщения торрент-бота в Telegram, реальные ответы qBittorrent WebUI API и сообщения торрент-бота в Telegram, реальные ответы qBittorrent WebUI API и
LLM-эндпоинта на живых раздачах. Автоматического сбора и корпуса кейсов **нет** LLM-эндпоинта на живых раздачах. Автоматического сбора и корпуса кейсов **нет**
это отдельная задача (eval-харнес распознавания), до неё числа здесь единичные и не планируется: размеченный корпус решено не собирать
и приведены как условия, а не как статистика. (`../tasks/REJECTED.md`, 2026-08-06). Числа здесь единичные и приведены как
условия, а не как статистика.
Зафиксированные образцы чужих форматов лежат прямо в тестах пакета-разборщика Зафиксированные образцы чужих форматов лежат прямо в тестах пакета-разборщика
(`internal/tgbot/parse_test.go`, `internal/magnet`, `internal/torrent`) — там там они заодно и проверяются; где именно и почему без каталога `testdata/`, см.
они заодно и проверяются; каталог `testdata/` под них не заводился. [CLAUDE.md](../../CLAUDE.md) → «Запреты».
## Записи ## Записи
- [torrent-bot-message.md](torrent-bot-message.md) — формат сообщения - [torrent-bot-message.md](torrent-bot-message.md) — формат сообщения
торрент-бота, из которого приходит magnet и контекст. торрент-бота, из которого приходит 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`).
+3 -1
View File
@@ -72,4 +72,6 @@ cправка: /help, index (https://exfreedomist.com/stats/)
- Как выглядит сообщение для сериала-сезонника и для аниме — образцов не - Как выглядит сообщение для сериала-сезонника и для аниме — образцов не
снимали, а именно на них строится самый сложный случай раскладки. снимали, а именно на них строится самый сложный случай раскладки.
- Что бот присылает при неудачном поиске и при слишком длинном описании - Что бот присылает при неудачном поиске и при слишком длинном описании
(обрезка Telegram — 4096 символов на сообщение). (лимит 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) и от
любого ручного прогона интеграционного теста: первый же живой ответ обязан
заменить здесь предположения на наблюдения, а слова «живым прогоном не
подтверждено» — на дату и результат прогона.
+394 -52
View File
@@ -1,11 +1,34 @@
# Ревью: настройка и журнал # Ревью: настройка и журнал
Проектная часть конвейера ревью: чем jellybit отличается от абстрактного Проектная часть конвейера ревью: чем jellybit отличается от абстрактного
Go-сервиса и что здесь уже проскакивало. Устройство самого конвейера (профили, 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) → «Что чувствительнее чего».
### Типовые узлы ### Типовые узлы
Рода узлов проекта и проверяемые свойства к каждому. Род, а не инвентарь Рода узлов проекта и проверяемые свойства к каждому. Род, а не инвентарь
@@ -54,6 +77,23 @@ Go-сервиса и что здесь уже проскакивало. Устр
- невалидный вход даёт доменную ошибку, а не тихий дефолт; - невалидный вход даёт доменную ошибку, а не тихий дефолт;
- результат нормализуется на границе (lowercase hex, trim, `ident.Parse`). - результат нормализуется на границе (lowercase hex, trim, `ident.Parse`).
**Вызов LLM и разбор его ответа** (`recognize`, `naming.Derive`)
Самый специфичный род узла в проекте: единственный, чей выход недетерминирован,
недоверен и стоит денег одновременно.
- решение узла **не является гейтом безопасности**: инъекция в промпт считается
состоявшейся, защита стоит ниже — на валидации целевого пути
([security.md](security.md));
- самооценка модели не заменяет матч в метабазе — порог `confidence` стоит
**поверх** матча дополнительным условием, а не вместо него;
- разбор ответа устойчив к лишним и недостающим полям, обрезке и не-JSON;
негодный ответ даёт доменную ошибку, а не тихий дефолт;
- попытка стоит лимита и денег: число ретраев берётся из конфига, а фоновый цикл
не запускает распознавание сам по себе повторно;
- тест не ходит в живой эндпоинт — провайдер за интерфейсом, ответ фикстурой;
живые прогоны только за env-гейтом.
**htmx-хендлер** **htmx-хендлер**
- один партиал обслуживает страницу и фрагмент, ветвление по `isHTMX`; - один партиал обслуживает страницу и фрагмент, ветвление по `isHTMX`;
@@ -70,10 +110,12 @@ Go-сервиса и что здесь уже проскакивало. Устр
снаружи LAN. снаружи LAN.
- **«Ошибка на htmx-пути возвращает 200».** Так и задумано — - **«Ошибка на htmx-пути возвращает 200».** Так и задумано —
[conventions/web-ui.md](conventions/web-ui.md). [conventions/web-ui.md](conventions/web-ui.md).
- **«Решение auto/review должно опираться на `confidence` модели».** Наоборот: - **«Решение auto/review должно опираться на `confidence` модели».** Неверно в
авто только при подтверждённом матче в базе — форме «вместо матча»: авто только при подтверждённом матче в базе —
[ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md). [ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md).
Самооценка LLM плохо откалибрована и поддаётся инъекции. Самооценка LLM плохо откалибрована и поддаётся инъекции. Порог `confidence`
при этом существует **дополнительным** блокирующим условием поверх матча —
его наличие дефектом не является.
- **«Копировать надёжнее, чем хардлинк» / «взять симлинк».** Хардлинк — - **«Копировать надёжнее, чем хардлинк» / «взять симлинк».** Хардлинк —
осознанный выбор ради неприкосновенности источника и недублирования диска, осознанный выбор ради неприкосновенности источника и недублирования диска,
[ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md); copy — только [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md); copy — только
@@ -91,86 +133,176 @@ Go-сервиса и что здесь уже проскакивало. Устр
`original_title` заполняется всегда и при неуверенности дублирует `title` `original_title` заполняется всегда и при неуверенности дублирует `title`
это контракт capability `recognition`, а не недосмотр. это контракт capability `recognition`, а не недосмотр.
### Вопросы к проходам ### Вопросы по темам
Форма: `<имя прохода>: <вопрос> (<провенанс>)`. Журнал дефектов пока пуст, Форма: `<тема>: <вопрос> (<провенанс>)`. Адресуется теме, а не имени прохода:
поэтому провенанс у всех пунктов — инвариант или ADR, а не пойманный случай; проход переезжает между метками и упраздняется, тема переезд переживает. Задаёт
по мере накопления журнала список должен смещаться в сторону реальных промахов. вопрос тот, кто закрывает тему на текущем прогоне.
- `adversary`: можно ли, управляя только именами файлов в раздаче и текстом - `security`: можно ли, управляя только именами файлов в раздаче и текстом
контекста, добиться целевого пути вне `paths.movies`/`series` — включая путь контекста, добиться целевого пути вне `paths.movies`/`series` — включая путь
через юникод, длину сверх лимита ФС и коллизию после нормализации? через юникод, длину сверх лимита ФС и коллизию после нормализации?
(инвариант «целевой путь строго под библиотекой», [security.md](security.md)) (инвариант «целевой путь строго под библиотекой», [security.md](security.md))
- `adversary`: есть ли последовательность команд, после которой снимается - `security`: есть ли последовательность команд, после которой снимается
**последняя** копия данных — с учётом `superseded`-ссылок и гонки со сверкой? **последняя** копия данных — с учётом `superseded`-ссылок и гонки со сверкой?
(инвариант «источник неприкосновенен») (инвариант «источник неприкосновенен»)
- `adversary`: что даёт крафт-магнет с чужим или подставным инфохэшем — - `security`: что даёт крафт-магнет с чужим или подставным инфохэшем —
присоединение к чужой активной загрузке, отравление владения? присоединение к чужой активной загрузке, отравление владения?
(открытая задача про идентичность инфохэшей) (открытая задача про идентичность инфохэшей)
- `ops`: что делает эта ветка, когда qBittorrent недоступен несколько минут - `security`: где признак «это наше» снимается с одной сущности, а действие
подряд — сколько ERROR-строк в секунду и меняется ли состояние задач? применяется к другой — присутствие раздачи в qBittorrent против байтов на
диске, запись в БД против файла, инфохэш против содержимого? (журнал,
2026-08-06: уборка своего торрента сносила чужие файлы)
- `operations`: что делает эта ветка, когда qBittorrent недоступен несколько
минут подряд — сколько ERROR-строк в секунду и меняется ли состояние задач?
(задача про ERROR-шторм фоновых циклов) (задача про ERROR-шторм фоновых циклов)
- `ops`: как это ведёт себя при сотне загрузок в базе и десятках тысяч - `operations`: как это ведёт себя при сотне загрузок в базе и десятках тысяч
`file_link` — есть ли запрос без индекса и полный проход по таблице? `file_link` — есть ли запрос без индекса и полный проход по таблице?
(задача про масштаб 100/1000, [database.md](database.md) → «Настройки») (задача про масштаб 100/1000, [database.md](database.md) → «Настройки»)
- `ops`: что остаётся на диске и в базе, если процесс убит посреди раскладки - `operations`: что остаётся на диске и в базе, если процесс убит посреди
батча? (состояние `linking` и его восстановление) раскладки батча? (состояние `linking` и его восстановление)
- `code`: логирующий чекпоинт один на операцию — или ошибка залогирована и - `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/logging.md](conventions/logging.md))
- `code`: новое поле конфига появилось в `config.example.toml` с описанием - `conventions`: новое поле конфига появилось в `config.example.toml` с
назначения, диапазона и единиц? ([conventions/config.md](conventions/config.md)) описанием назначения, диапазона и единиц?
- `specs`: не завелось ли поведение, которого спека не заказывала — тихий ([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`: не появился ли второй способ делать то, что уже делается — - `architecture`: не появился ли второй способ делать то, что уже делается —
второе место, где генерится время или id, второй парсер источника, вторая второе место, где генерится время или id, второй парсер источника, вторая
логика целевых имён мимо `naming`? логика целевых имён мимо `naming`?
([architecture.md](architecture.md) → «Единые точки проекта») ([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` не дала ни враждебного прохода, ни замера, а именно они нашли
четыре дефекта класса «необратимо».
- **`deep`** — есть миграция в `internal/store/migrations/`; появляется новый
пакет `internal/*`; меняется сигнатура публичной команды воркера; трогается
раскладка файлов, построение целевых путей или удаление ссылок; трогается
разбор недоверенного входа.
- **`standard`** — меняется поведение, видимое снаружи: REST-эндпоинт,
htmx-путь, набор или семантика состояний загрузки, формат сообщения бота,
поле конфига.
- **`quick`** — всё остальное: локальный багфикс, документация, тесты.
- **Независимая реализация (`reimpl`)** запускается, когда узел одновременно
новый и имеет внешний оракул в виде спеки: новый парсер, новый провайдер за
существующим интерфейсом, новая стадия конвейера распознавания.
- «Поведение, видимое снаружи» здесь включает **тексты и карточки Telegram** - «Поведение, видимое снаружи» здесь включает **тексты и карточки Telegram**
для единственного пользователя это и есть интерфейс. для единственного пользователя это и есть интерфейс.
**Место из перечня метку не поднимает — поднимает правило.** Перечни выше
отвечают «здесь такие правила водятся», а не «любая правка здесь идёт в
`large`». Метку поднимает то, что даёт работу новому проходу: заводится ключ
сравнения или меняется его состав; у разбора появляется новый вид входа или
новая ветка неоднозначности; в слияние добавляется источник или меняется
победитель при конфликте.
**Мелкое здесь** — опускает до `small`. Перечень закрытый, каждый пункт
проверяется взглядом на дифф и дельта-спеку, любое сработавшее держит метку
внизу:
- **дельта-спека называет исход поимённо** — сценарий уже говорит, что даёт
вырожденный вход, и решать в коде нечего;
- **новых сценариев в дельта-спеке нет** — изменение уточняет уже описанное
поведение, а не заказывает новое;
- **правка сообщения, комментария, записи журнала, имени или теста** в узле из
перечней выше;
- **сужение уже существующей нормализации** без нового вида входа: вход остался
тот же, изменился исход на одном его значении.
Отрицательный тест поверх перечня: что после мерджа не откатывается обратной
правкой — миграция, формат на диске, публичный контракт, имя, — **не** `small`,
каким бы маленьким ни был дифф.
**Ориентир частоты.** `medium` закрывает большинство задач, `large` рассчитана
на 5–10% и приходится на крупную функциональность, а не на уборку: задача типа
`chore` или `fix`, собранная из нитей прошлого ревью, идёт в `small` или
`medium`, даже когда трогает файл из перечней выше. `large` чаще одной задачи из
десяти означает ошибку в критерии, а не полосу сложных задач подряд.
### Недоступно проверке ### Недоступно проверке
**Не проверит ни один проход** — принципиальная граница, по факту промаха не **Не проверит ни один проход** — принципиальная граница, по факту промаха не
пересматривается. пересматривается.
- История инцидентов на umbar и то, что уже ломалось в проде. - `operations`: история инцидентов на umbar и то, что уже ломалось в проде.
- Поведение таблицы SQLite под реальным объёмом и профилем нагрузки: реального - `operations`: поведение таблицы SQLite под реальным объёмом и профилем
профиля нет ни у кого, кроме сервера. нагрузки — реального профиля нет ни у кого, кроме сервера.
- Завязка внешних потребителей (Jellyfin, закладки, чужие ссылки) на текущее - `architecture`: завязка внешних потребителей (Jellyfin, закладки, чужие
поведение. ссылки) на текущее поведение.
- Качество распознавания как таковое: правильно ли LLM определил фильм — вопрос - `architecture`: суждение «этой функциональности не должно существовать».
eval-харнеса и корпуса кейсов, а не ревью кода. - `requirements`: качество распознавания как таковое — правильно ли LLM
- Суждение «этой функциональности не должно существовать». определил фильм. Это вопрос тюнинга модели и промпта, а не ревью кода;
размеченный корпус, по которому это можно было бы судить числом, решено не
собирать (`tasks/REJECTED.md`, 2026-08-06).
**Перестали проверять сознательно** — пересматривается первым, как только **Перестали проверять сознательно** — пересматривается первым, как только
что-то проскочило. что-то проскочило.
- **Идиоматичность Go — с 2026-08-04.** Проектный проход `idiom` (поимённая - `conventions`: **идиоматичность Go — с 2026-08-04.** Проектный проход `idiom`
сверка с положениями Effective Go, Go Code Review Comments, стайлгайдов Uber (поимённая сверка с положениями Effective Go, Go Code Review Comments,
и Google) удалён вместе с проектными копиями агентов при переезде на плагин стайлгайдов Uber и Google) упразднён вместе с переездом конвейера в плагин
`av-dev-pipeline`, который этот проход упразднил. Способные части переселены: ([ADR-2026-08-04-review-pipeline-to-plugin](adr/ADR-2026-08-04-review-pipeline-to-plugin.md));
эксперимент против поведения библиотеки и драйвера — в `ops`, «не изобретаем способные части переселены — в тему `operations` (эксперимент против
ли то, что уже есть в библиотеке» — в `architecture`. **Различение поведения библиотеки и драйвера) и в `architecture` («не изобретаем ли то,
«идиоматично против распространено» теперь не спрашивает никто.** Класс что уже есть в библиотеке»). **Различение «идиоматично против
обратимый: портит форму кода, не данные. Пересмотр — задача распространено» теперь не спрашивает никто.** Класс обратимый: портит форму
`quality-review-agents`. кода, не данные. Пересмотр — задача `quality-review-agents`.
- `security`, `operations`, `architecture`: на метках `small` и `medium` не
проверяется ничто, требующее запуска, — построенных путей атаки, замеров и
эксплуатационного постмортема там нет по устройству конвейера. Их даёт только
`large`, а она приходится на 5–10% задач.
## Журнал дефектов ## Журнал дефектов
@@ -195,7 +327,217 @@ Go-сервиса и что здесь уже проскакивало. Устр
### Записи ### Записи
Пока пусто. Журнал заведён 2026-07-23 вместе с переработкой конвейера Журнал заведён 2026-07-23 вместе с переработкой конвейера
([ADR-2026-07-23-review-pipeline-generative](adr/ADR-2026-07-23-review-pipeline-generative.md)); ([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`.
+8 -3
View File
@@ -29,9 +29,10 @@ REST API работают **без авторизации** осознанно;
| Текстовый контекст человека | все транспорты | попадает в промпт LLM целиком | | Текстовый контекст человека | все транспорты | попадает в промпт LLM целиком |
| Сообщение торрент-бота | Telegram (пересылка) | чужой формат, парсер, ссылки; текст автора бота, а не отправителя | | Сообщение торрент-бота | Telegram (пересылка) | чужой формат, парсер, ссылки; текст автора бота, а не отправителя |
| **Ответ LLM** | HTTP к эндпоинту | целиком под влиянием входа выше; названия, годы, номера сезонов и серий, из которых строится целевой путь | | **Ответ LLM** | HTTP к эндпоинту | целиком под влиянием входа выше; названия, годы, номера сезонов и серий, из которых строится целевой путь |
| Ответы метабаз | HTTP к TMDB/TVDB/TVMaze | канонические названия, из которых тоже строится путь | | Ответы метабаз | 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 | пути, состояния, размеры | | Ответы qBittorrent | HTTP | пути, состояния, размеры |
| Запросы веб-UI и REST | LAN | идентификаторы, параметры действий | | Запросы веб-UI и REST | LAN | идентификаторы, параметры действий |
| Пачка идентификаторов и признак подтверждения на групповом удалении | форма веб-UI | необратимое действие сразу по многим загрузкам; разбор, схлопывание дублей и предел размера стоят на **обеих** границах — подтверждении и исполнении, потому что вторая получает пачку формой заново |
**Выход LLM не отвечает за безопасность.** Инъекция в промпт считается **Выход LLM не отвечает за безопасность.** Инъекция в промпт считается
состоявшейся по умолчанию; защита стоит ниже — на валидации целевого пути. состоявшейся по умолчанию; защита стоит ниже — на валидации целевого пути.
@@ -49,7 +50,10 @@ REST API работают **без авторизации** осознанно;
- **Правило:** компоненты санитизируются (убираются разделители пути, `..`, - **Правило:** компоненты санитизируются (убираются разделители пути, `..`,
управляющие символы), финальный `filepath.Clean`-путь обязан быть **строго управляющие символы), финальный `filepath.Clean`-путь обязан быть **строго
под** соответствующей библиотекой, иначе операция отклоняется. Проверка на под** соответствующей библиотекой, иначе операция отклоняется. Проверка на
результате, а не на входе. результате, а не на входе. Следом — длина: каждый компонент обязан помещаться в
255 байт UTF-8, иначе задача уходит в `review`. Порядок значим: путь, вышедший
за песочницу, отклоняется как выход за библиотеку, а не как длинное имя, иначе
находка безопасности спряталась бы за косметической причиной.
- **Исходный путь** = `save_path` из qBittorrent + относительное имя файла из - **Исходный путь** = `save_path` из qBittorrent + относительное имя файла из
`/torrents/files`. Тоже недоверенный, но по нему мы только **читаем и `/torrents/files`. Тоже недоверенный, но по нему мы только **читаем и
линкуем**; писать в `paths.downloads` нельзя вообще. линкуем**; писать в `paths.downloads` нельзя вообще.
@@ -100,7 +104,8 @@ REST API работают **без авторизации** осознанно;
- **Отказ в обслуживании изнутри контура.** Огромная раздача, тысяча файлов, - **Отказ в обслуживании изнутри контура.** Огромная раздача, тысяча файлов,
бесконечный ответ LLM — это вопросы устойчивости и ресурсов бесконечный ответ LLM — это вопросы устойчивости и ресурсов
([architecture.md](architecture.md) → «Эксплуатация»), а не безопасности. ([architecture.md](architecture.md) → «Эксплуатация»), а не безопасности.
Отсутствие лимита на размер ответа LLM — известный пробел, задача в беклоге. Тело ответа внешнего сервиса при этом читается с пределом: LLM — 8 MiB
(`internal/llm`), метабазы — 4 MiB (`internal/metadata`).
- **Целостность содержимого медиафайлов.** Что в контейнере mkv — не наша забота. - **Целостность содержимого медиафайлов.** Что в контейнере mkv — не наша забота.
- **Цепочка поставки** — модули Go, базовый образ distroless, плагины тулинга. - **Цепочка поставки** — модули Go, базовый образ distroless, плагины тулинга.
- **Приватность запросов к внешним сервисам.** Названия раздач уезжают в LLM и - **Приватность запросов к внешним сервисам.** Названия раздач уезжают в LLM и
-51
View File
@@ -1,51 +0,0 @@
# Беклог
Что **можно взять**. Одна задача = один файл `items/<slug>.md`
+ строка здесь. Целей тут нет — они в [PLAN.md](PLAN.md): беклог — то, что берут,
план — то, подо что берут. Порядка внутри секции нет: «что делать
дальше» отвечает набор спринта. Ведётся скиллом `tasks`.
Секции «блокеры» здесь нет и не заводится: блокер — это состояние
(спринт не может продолжаться ни одной задачей), оно живёт до ответа
человека, а его следы — вопросами в файлах задач.
## Ядро продукта
- [Аниме с абсолютной нумерацией](items/anime-absolute-numbering.md) — аниме со сквозной нумерацией (#137) не раскладывается в SxxEyy, который ждёт Jellyfin — нужен пересчёт абсолютной нумерации
- [`addReq` не пересобирается из свежего `source_type` перед `Add` (окно namer'а)](items/catched-source-type-refresh.md) — При апгрейде magnet→.torrent в окне namer'а добавится magnet из устаревшего снимка; самоисцеляется через magnet_timeout→failed→Retry _(аудит 2026-07-17)_
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](items/disc-image-releases.md) — раздача-образ диска (VIDEO_TS/BDMV) сейчас разбирается пофайлово вместо раскладки каталога целиком — редкий, но реальный случай
- [Веб-UI зовёт Cancel вместо Dismiss на не-терминальных → теряется `user_dismiss`](items/dismiss-marker-lost.md) — Функционально ок (Cancel даёт cancelled), но маркер user_dismiss в error_code теряется; расхождение с буквой спеки _(аудит 2026-07-17)_
- [Фетч .torrent по URL — остаток «единого окна»](items/torrent-url-fetch.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](items/auto-link-confidence-gate.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
- [[idea] guessit как сервис-спутник](items/guessit-sidecar.md) — go-ptn слабее питоновского guessit — если точности пред-парса не хватит, завернуть guessit в сервис-спутник рядом с бинарём
- [Согласование канона нумерации серий с провайдером тега](items/episode-numbering-canon.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега
- [Раздачи с докачиванием (merge при повторном добавлении)](items/merge-incremental-redownload.md) — повторная заливка сериала целиком должна доложить недостающие эпизоды merge-раскладкой, не трогая существующие ссылки — блокирует типовой сценарий свежих сериалов
- [[idea] Многоступенчатая верификация привязки](items/multi-pass-verification.md) — несколько проходов распознавания с консенсусом подняли бы точность ценой стоимости/латентности — проработать, когда включать и как мерджить расхождения
- [Обучение на правках человека (few-shot из прошлых ревью)](items/learn-from-user-corrections.md) — правки человека (матч/тип/нумерация) не переиспользуются — few-shot из прошлых ревью поднял бы точность на «своих» трекерах без смены модели
- [Идентичность инфохэшей: split v1/v2 одного торрента + крафт-магнет отравляет владение (F4, F5)](items/infohash-identity-integrity.md) — split v1/v2 идентичность и крафт-магнет отравляют владение инфохэшами _(ревью 2026-07-08)_
- [Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)](items/ingest-nits.md) — косметика приёма: NoName в контексте, устаревшие комментарии, лог, bencode-аллокации _(ревью 2026-07-08)_
- [[idea] Сила совпадения кандидата и пересмотр распознавания/матчинга](items/candidate-match-strength.md) — у кандидата метабазы нет метрики силы совпадения — список кандидатов на ревью нечем отсортировать по уверенности (сперва проработать процесс матчинга)
- [[idea] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки](items/complex-series-releases.md) — сложные раздачи (все сезоны разом, паки, смешанная нумерация) целостно не проработаны — распознавание/ревью/раскладка заточены под один сезон
- [Мгновенные обновления через SSE](items/sse-live-updates.md) — живые обновления на htmx-поллинге дают задержку и холостые запросы — SSE убрал бы то и другое (поллинг работает, поэтому улучшение, не блокер)
- [Проверка свободного места перед copy-fallback](items/free-space-check-copy-fallback.md) — copy-fallback при невозможности хардлинка может упереться в переполненный диск посреди раскладки — нет проверки места до копирования
- [Ревью уведомлений в Telegram (аудит текстов и формата)](items/telegram-messages-audit.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи
- [Привязка уведомлений к источнику в ботах (мульти-бот)](items/notification-source-binding.md) — пинги и ревью должен получать автор загрузки в своём транспорте — нет привязки загрузки к источнику/отправителю (нужно для мульти-бота)
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](items/title-versions-repacks.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
- [Внешние субтитры: пары VobSub и языковой суффикс](items/external-subtitles.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
- [Современный Web-UI как PWA](items/web-ui-pwa.md) — текущий server-rendered UI функционален — PWA (устанавливаемое, удобное с телефона) это улучшение большого объёма, не блокер
- [Полный редактор маппинга «файл → серия» и ручной режим ревью](items/review-mapping-editor.md) — правка S·E, «нумеровать подряд» и ручной режим при полном провале LLM были запланированы объёмом Ф5 и не заведены задачей — в ревью сегодня можно только подсказать текстом
- [Крайние случаи именования: многофайловый фильм, редакции, двойная серия](items/naming-edge-cases.md) — стэкинг частей (part1/cd1), редакции [edition-…] и двойная серия SxxEyy-Eyy описаны нарративом, но в file-layout не заказаны — раскладка таких раздач не определена
## Инфраструктура
- [Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)](items/quality-review-agents.md) — конвейер ревью переехал в плагин `av-dev-pipeline`; осталась калибровка проходов на этом проекте и ревьювер наименований (ждёт словарь единого языка)
- [Авторизация веб-UI (на будущее)](items/web-ui-auth.md) — для v1 решено без авторизации (доверенная LAN, опц. allowlist подсетей) — задел на случай, если понадобится защита
- [Бэкап SQLite](items/sqlite-backup.md) — architecture требует бекапить data-том, но стратегия не описана — сбой или редеплой стирают всё in-flight состояние (проще, пока БД маленькая)
- [Eval-харнес распознавания (корпус кейсов + метрика точности)](items/recognition-eval-harness.md) — смена модели или правка промпта распознавания сейчас вслепую — нет корпуса кейсов и метрики точности, регрессии не видно
- [Глубокий healthcheck и статус зависимостей](items/deep-healthcheck-dependencies.md) — /healthz проверяет только сам сервис — недоступность qBittorrent/LLM/метабазы видна лишь по застрявшим задачам, нет readiness и бейджа в UI
- [История переходов загрузки](items/download-transition-history.md) — хранится только текущий статус загрузки — разбор «как сюда попали» идёт по логам сервера, нет таблицы истории переходов
- [Кэш метабаз (и опционально LLM)](items/metadata-cache.md) — повторные и ретраящиеся прогоны бьют TMDB/TVDB/TVMaze одним запросом — кэш с TTL сэкономил бы лимиты и ускорил «Распознать заново»
- [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](items/scale-100-downloads.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
- [Шум ERROR фоновых циклов при недоступной зависимости](items/background-error-noise.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
- [Ретеншн и очистка БД](items/db-retention-cleanup.md) — терминальные задачи и сырые ответы LLM копятся вечно — без авточистки список загрузок и БД деградируют по мере эксплуатации
- [Словарь единого языка (ubiquitous language)](items/ubiquitous-language-glossary.md) — наименования домена расходятся между спеками, UI и кодом — нет единого глоссария (на нём же стоит агент-ревьювер наименований)
- [[idea] Завершение загрузки через webhook](items/completion-webhook.md) — завершение сейчас ловим поллингом qBittorrent — webhook реагировал бы быстрее, но связывает нас с его конфигом (решим по опыту эксплуатации)
- [[idea] Кандидаты в конвенции кода](items/convention-candidates.md) — накоплен список кандидатов (внешние клиенты, конкурентность, тесты, CLI, время) — надо решить, что из них стало реальным трением, а что выдумано вперёд
-22
View File
@@ -1,22 +0,0 @@
# План
Оглавление целей. Цель — файл `[goal]` в `items/`; её задачи
здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.
В первой секции («порядок») очередь значима и обосновывается
прозой; в остальных порядка нет — это тематические цели.
## порядок
Пусто. Фазы Ф0–Ф6 прежней дорожной карты (каркас, приём и трекинг,
распознавание, раскладка и ревью, метаданные, Telegram и UX, деплой) закрыты —
сквозной путь работает и развёрнут; закрытый шаг планом больше не является.
Что было сделано и когда — по архиву `openspec/changes/archive/`.
Следующая упорядоченная очередь появится, когда она понадобится.
## темы
- [[goal] Точность распознавания](items/recognition-accuracy.md) — смена модели или правка промпта сегодня вслепую — нет ни метрики, ни способа переиспользовать уже сделанные человеком правки
- [[goal] Сложные раздачи](items/complex-releases.md) — типовая раздача раскладывается, а всё, что сложнее одного сезона одного тайтла, упирается в ручной разбор
- [[goal] Эксплуатационная прочность](items/operational-resilience.md) — сервис работает, но не переживает роста: база копится вечно, бэкапа нет, отказ зависимости виден только по застрявшим задачам
- [[goal] Интерфейсы приёма и ревью](items/ingest-and-review-interfaces.md) — путь «принести раздачу и подтвердить догадку» упирается в незакрытые куски интерфейсов, а не в логику
- [[goal] Целостность состояния и приёма](items/state-integrity.md) — известные окна рассинхрона и потери маркеров: каждое по отдельности самоисцеляется, вместе — источник необъяснимых состояний
- [[goal] Процесс и качество разработки](items/dev-process-quality.md) — наименования домена расходятся между спеками, UI и кодом, а конвейер ревью не откалиброван — растёт цена каждой следующей задачи
-7
View File
@@ -1,7 +0,0 @@
# Ушедшее без реализации
Задачи, покинувшие беклог **без реализации**, с причиной и датой.
Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них
есть коммит. Это первое место, куда смотрит дедупликация при заведении.
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->
-6
View File
@@ -1,6 +0,0 @@
# Спринт
Спринта нет. Цель называет человек, набор собирает агент:
`tasks.py sprint start --goal <слаг>`.
## Набор
@@ -1,56 +0,0 @@
# Шум ERROR фоновых циклов при недоступной зависимости
- **Секция:** инфраструктура
- **Зачем:** Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
- **Теги:** goal:operational-resilience
Остаток от задачи «классификация доменных ошибок + конвенции логирования»
(основное реализовано, см. ниже). Здесь — два смежных пункта про уровень
повторяющихся сбоев фоновых циклов, каждый требует небольшого решения, а не
только правки.
## Что уже сделано (не переоткрывать)
Коммит `f8fb4fa` (Tier A) + коммит этой задачи закрыли:
- **Классификация доменных ошибок:** sentinel `worker.ErrInvalidInput`→400;
обёртки `ErrConflict` в Cancel/Retry/Defer/Undo; `layout.ErrCollision`→409 в
`classifyErr` и ветка в tgbot; `logCmd` относит новые классы в DEBUG.
- **Конвенции:** `logging.md` — команды воркера = доменная граница, таблица
уровней доменных отказов (граница команды vs асинхронная стадия), правило про
`*url.Error`/секреты в URL, канон категории `state transition` (унифицированы
cancel/retry/relink/recovery). `errors.md` — таблица маппинга ошибка→статус,
развилка «транзиентный ответ vs персистентная диагностика» решена как (а):
`error_msg`/`reasons` — операторская поверхность владельца (сырой текст ок,
секреты запрещены; аудит показал, что секреты туда не текут).
- **Мелочи:** reason-коды const-блок; лог-поля `id``download_id`; preview
WARN; комментарий у `parseIgnored`.
## Остаток
### ERROR-шторм при недоступном qBittorrent
Клиент `qbt` логирует `ext.*` `Failure`**ERROR** на каждом тике поллинга
(`torrents/info`, `internal/qbt/qbt.go`), пока qBittorrent недоступен (рестарт
демона, сеть). Домен уже пишет `poll failed` = WARN (по новой конвенции), но
транспортная `ext.*`-запись остаётся ERROR по правилу ext-конвенции («сервис
недоступен → ERROR»). При частом поллинге это шумит.
Развилка (решить до правки):
- (а) Ввести у `logging.ExtCall` вариант с пониженным уровнем для рутинно-частых
вызовов (симметрично `SuccessDebug`) — поллинг-вызовы (`torrents/info`) на
транзиентном сбое пишут WARN, не ERROR;
- (б) Дедуп/circuit-breaker: первый ERROR, дальше тишина до восстановления;
- (в) Оставить как есть, признав `ext.*` ERROR легитимным сигналом «зависимость
лежит» (тогда шум гасить уровнем сбора, а не кодом).
### Эскалация устойчивого сбоя тика
Сейчас транзиентный сбой тика = WARN всегда. Договорённость на будущее
(`logging.md`): устойчивый сбой N тиков подряд эскалировать в ERROR (реальная
деградация, а не разовый промах). Не реализовано — нужен счётчик подряд-сбоев по
циклу и порог в конфиге.
Вердикт: мелкая надёжностная полировка, не блокер. Делать вместе (обе про
уровень сбоев фоновых циклов) или отдельной строкой.
@@ -1,60 +0,0 @@
# `addReq` не пересобирается из свежего `source_type` перед `Add` (окно namer'а)
- **Секция:** ядро продукта
- **Зачем:** При апгрейде magnet→.torrent в окне namer'а добавится magnet из устаревшего снимка; самоисцеляется через magnet_timeout→failed→Retry _(аудит 2026-07-17)_
- **Теги:** goal:state-integrity
Найдено аудитом capability **download-tracking** (сверка код↔спека после пачки
lifecycle-задач). Пред-существующее, вне scope задачи F3/cancel-cleanup — T4
осознанно вынес это за рамки и задокументировал в своём design.md.
## Суть
`processCatched` (`internal/worker/worker.go:442-517`) строит `addReq` из записи
`cur`, перечитанной под замком на `:447-455`, **до** вызова namer'а (LLM, секунды,
вне замка, `:463-478`). Затем на `:484-491` под замком перечитывается `before`,
но `addReq` из него **не пересобирается** — проверяется только `state == catched`.
Если апгрейд пойманной magnet-задачи до `.torrent`
(`UpgradeCatchedMagnetToTorrent`, `internal/store/download.go:392`) отработает
именно в окне namer'а (приём принял `.torrent` с тем же infohash, `state`
остаётся `catched`), воркер добавит **magnet-ссылку из устаревшего снимка**, хотя
в БД уже `source_type=torrent`.
Спека (`openspec/specs/download-tracking/spec.md`, раздел про добавление по
`source_type`) требует перечитывать `source_type` **под блокировкой переходов
непосредственно перед добавлением** — сейчас это требование в окне namer'а
нарушается.
## Насколько больно
Ограниченно и самоисцеляемо: на закрытом трекере magnet без метаданных зависнет
в `metaDL` → предохранитель `magnet_timeout``failed`; ручной `Retry`
перечитает актуальный `source_type` и добьёт. Данные не страдают, инвариант
«источник неприкосновенен» не задет. Окно узкое (апгрейд должен лечь ровно в
LLM-вызов по тому же infohash). Поэтому средний, не высокий.
## Развилка (решить до кода)
- **A — ужесточить код (соответствие букве спеки, закрыть окно):** после re-read
`before` под замком (`:484`) пересобирать `addReq`/`hint` из `before`, если
`source_type` изменился. Нюанс: `sourceAddParts` читает байты `.torrent` — это
тяжёлый вызов, держать под замком нельзя (спека: тяжёлое — вне блокировки), плюс
подсказка имени для `.torrent` иная (метаданные раздачи vs имя из magnet), т.е.
при апгрейде корректно был бы и повторный namer. Не однострочник.
- **B — смягчить спеку (принять реальность):** признать, что рациональ («не
полагаться на снимок, снятый ранее вне блокировки») уже выполнен первым re-read
под замком на `:447`, и переформулировать требование как «перечитывать
`source_type` под блокировкой после тик-снимка», явно приняв узкое namer-окно
как самоисцеляемое через `Retry`.
Рекомендация — начать с B (дёшево, отражает фактическое осознанное поведение), A
завести только если узкое окно окажется реальной болью в эксплуатации.
## Ссылки
- `internal/worker/worker.go:442-517``processCatched`
- `internal/store/download.go:392``UpgradeCatchedMagnetToTorrent`
- `openspec/specs/download-tracking/spec.md` — требование про `source_type`
- Тест `TestProcessCatchedReReadsSourceTypeUnderLock` покрывает апгрейд между
тик-снимком и re-read, но **не** окно namer'а.
-9
View File
@@ -1,9 +0,0 @@
# Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)
- **Секция:** ядро продукта
- **Зачем:** раздача-образ диска (VIDEO_TS/BDMV) сейчас разбирается пофайлово вместо раскладки каталога целиком — редкий, но реальный случай
- **Теги:** goal:complex-releases
Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска — структура 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.
-49
View File
@@ -1,49 +0,0 @@
# Веб-UI зовёт Cancel вместо Dismiss на не-терминальных → теряется `user_dismiss`
- **Секция:** ядро продукта
- **Зачем:** Функционально ок (Cancel даёт cancelled), но маркер user_dismiss в error_code теряется; расхождение с буквой спеки _(аудит 2026-07-17)_
- **Теги:** goal:state-integrity
Найдено аудитом capability **state-reconciliation** (сверка код↔спека).
Пред-существующее, вне scope пачки lifecycle-задач.
## Суть
Спека `openspec/specs/state-reconciliation/spec.md` (требование «Ручное
закрытие»): команда `dismiss` доступна из **любого** состояния кроме `deleted`
**во всех транспортах**, и переход SHALL помечаться `error_code = user_dismiss`.
В веб-UI danger-zone «Закрыть» (dismiss) гейтится только для терминальных
состояний: `Dismissable = IsTerminal() && !deleted && !cancelled`
(`internal/httpapi/download.go:130`, шаблон
`web/templates/partials/download_main.html:99-112`). Для НЕ-терминальных
(`stuck`, `deferred`, `downloading`, `review`) закрытие в UI идёт кнопкой
«Отменить» → `Cancel` (`internal/worker/worker.go:973`), которая пишет **пустой**
`error_code`, а не `user_dismiss`.
## Насколько больно
Функционально сценарии проходят: `Cancel` тоже даёт `cancelled` и не трогает
файлы/раздачу, семантика для пользователя идентична. Состояния без доступного
«закрытия» нет (кроме `deleted`/`cancelled`). Теряется только маркер
`user_dismiss` в `error_code` — расхождение с буквой спеки и небольшая потеря
наблюдаемости (в аналитике/логах не отличить «пользователь закрыл активную» от
«пользователь отменил»). Отсюда низкий приоритет.
## Развилка (решить до кода)
- **A — привести код к спеке:** веб-UI на не-терминальных тоже зовёт `Dismiss`
ради единого маркера `user_dismiss`; либо `Cancel` пишет `user_dismiss`.
- **B — привести спеку к коду:** зафиксировать осознанное разделение (`Cancel`
для активных, `Dismiss` для терминальных) — уточнить требование, что стоп-кран
на не-терминальных реализуется `Cancel`'ом, и определить, какой `error_code`
ожидается.
Сначала решить, осознанно ли разделение Cancel/Dismiss; если да — вероятно B.
## Ссылки
- `internal/httpapi/download.go:130` — гейт `Dismissable`
- `web/templates/partials/download_main.html:99-112` — danger-zone
- `internal/worker/worker.go:973``Cancel`; `:1001``Dismiss`
- `openspec/specs/state-reconciliation/spec.md` — требование «Ручное закрытие»
-17
View File
@@ -1,17 +0,0 @@
# Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)
- **Секция:** ядро продукта
- **Зачем:** косметика приёма: NoName в контексте, устаревшие комментарии, лог, bencode-аллокации _(ревью 2026-07-08)_
- **Теги:** goal:state-integrity
Ревью Fable 2026-07-08 (приём). Косметические нити.
N1 — torrent.go:114: Context() включает NoName-сентинел «-» как строку-название (name != "" проходит); ingest.parse фильтрует «-» только для source_ref (ingest.go:160-162). Одна грязная строка контекста для безымянных торрентов. Фикс: фильтровать «-» и в Context().
N3 — устаревшие комментарии. httpapi.go:574-577 и tgbot/bot.go:256-258 утверждают, что res.DownloadID может быть непуст при ошибке приёма («сбой после создания задачи, напр. qbit»). После fast-catch рефактора Ingest возвращает Result{} на КАЖДОМ пути ошибки (ingest.go:74-75,86,106-107) → корреляция всегда падает на request_id / без ключа. Фикс: поправить комментарии.
N4 — qbt.go:246 логирует «Fails.» со счётчиками, но qBittorrent не даёт причину; вместе с F2 оператор не отличит «дубль» от «битый файл». Идея: логировать хеши/первые байты для корреляции.
N5 — anacrolix bencode (v1.61.0, bencode/decode.go:17,250) аллоцирует до MaxStrLen (~128MiB) на объявленную строку до чтения — крафт-8MiB-торрент может форсить транзиентные ~128MiB аллокации при metainfo.Load. Ограничено и завершается ошибкой; на umbar приемлемо, но знать стоит. (files()-panic-guard torrent.go НЕ покрывает Load/UnmarshalInfo/HashBytes, но panic-путей там не найдено.)
Вердикт: простые фиксы/принять.
@@ -1,9 +0,0 @@
# Обучение на правках человека (few-shot из прошлых ревью)
- **Секция:** ядро продукта
- **Зачем:** правки человека (матч/тип/нумерация) не переиспользуются — few-shot из прошлых ревью поднял бы точность на «своих» трекерах без смены модели
- **Теги:** goal:recognition-accuracy
Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать похожие в будущие промпты. Системно повышает точность на «твоих» трекерах и форматах имён без смены модели. Развитие идеи многоступенчатой верификации, но дешевле: учимся на уже собранных hint/override.
Связано: specs/recognition.md (конвейер, промпт), «Многоступенчатая верификация», specs/architecture.md → «Хранилище» (hint, override).
-11
View File
@@ -1,11 +0,0 @@
# [goal] Точность распознавания
- **Секция:** темы
- **Зачем:** смена модели или правка промпта сегодня вслепую — нет ни метрики, ни способа переиспользовать уже сделанные человеком правки
- **Теги:** decomposed
Ради чего: распознавание — единственное место, где система может ошибиться молча и правдоподобно. Сегодня её точность не измеряется ничем, кроме впечатления, а накопленные правки человека пропадают.
## Завершение
Достигнута, когда точность распознавания меряется числом на фиксированном корпусе реальных раздач, смена модели или правка промпта прогоняются через этот корпус до выкатки, а решение auto/review опирается на измеримую силу совпадения, а не на самооценку модели.
@@ -1,9 +0,0 @@
# Eval-харнес распознавания (корпус кейсов + метрика точности)
- **Секция:** инфраструктура
- **Зачем:** смена модели или правка промпта распознавания сейчас вслепую — нет корпуса кейсов и метрики точности, регрессии не видно
- **Теги:** goal:recognition-accuracy
Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую: регрессий не видно. Нужен корпус размеченных кейсов (русские релизы, аниме, сезон-паки, репаки, спецвыпуски) и прогон распознавания по нему с метрикой точности (тип/название/год/нумерация). Тогда можно сравнивать LLM-провайдеры и версии промпта по числам. Прогон — отдельной командой (jellybit eval или тестом), на фикстурах, без реального qBittorrent.
Связано: specs/recognition.md (конвейер, модель уверенности), пакет recognize.
+2 -2
View File
@@ -284,8 +284,8 @@ func (c *Config) validate() error {
// Язык локализованного вывода: пусто (→ en) или один из кодов. Fail-fast, // Язык локализованного вывода: пусто (→ en) или один из кодов. Fail-fast,
// как llm.type: мусорное значение не должно молча дефолтить. Множество // как llm.type: мусорное значение не должно молча дефолтить. Множество
// {ru, en} — канон; при добавлении кода синхронно расширь мапперы // {ru, en} — канон; при добавлении кода синхронно расширь мапперы
// metadata.tmdbLocale и recognize.languageDirective, иначе новый язык молча // metadata.tmdbLocale, metadata.tvdbLocale и recognize.languageDirective,
// даст английский вывод. // иначе новый язык молча даст английский вывод.
switch c.General.Language { switch c.General.Language {
case "", "ru", "en": case "", "ru", "en":
default: default:
+93 -4
View File
@@ -2,6 +2,7 @@ package httpapi
import ( import (
"context" "context"
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -337,8 +338,9 @@ func TestSourceSwapUpdatesActionBarOOB(t *testing.T) {
}) })
} }
// TestRetryListShowsProgress: retry из списка → карточка downloading с // TestRetryListShowsProgress: retry из списка → карточка downloading с живым
// прогресс-поллером. // прогрессом и самообновлением карточки (опрашивает себя карточка, а не
// вложенный блок прогресса).
func TestRetryListShowsProgress(t *testing.T) { func TestRetryListShowsProgress(t *testing.T) {
dl := dlState(store.StateDownloading) dl := dlState(store.StateDownloading)
lv := stubLive{m: map[string]worker.Live{"ihswap": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}} lv := stubLive{m: map[string]worker.Live{"ihswap": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
@@ -348,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)
}
}
}
+16 -4
View File
@@ -21,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
@@ -89,6 +90,15 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
} }
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil { if err != nil {
// Тик самообновления страницы идёт этим же маршрутом (hx-get="/download/{id}"
// с hx-select="#download-main"). Отвечать ему статусом ошибки нельзя: htmx не
// свопит 4xx/5xx и не снимает hx-trigger — страница осталась бы навсегда
// устаревшей, а опрос продолжался бы до закрытия вкладки. Навигационный GET
// (адресная строка, закладка) по-прежнему получает честный статус.
if isHTMX(r) {
s.fragTickErr(w, err, id, "download-main")
return
}
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
http.Error(w, "задача не найдена", http.StatusNotFound) http.Error(w, "задача не найдена", http.StatusNotFound)
return return
@@ -113,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,
@@ -124,8 +137,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
d.State == store.StateTargetMissing, d.State == store.StateTargetMissing,
Retriable: d.State == store.StateFailed || d.State == store.StateStuck, Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Deletable: d.State == store.StateDone || d.State == store.StateOrphaned || Deletable: d.State.CanDelete(),
d.State == store.StateTargetMissing,
Dismissable: d.State.IsTerminal() && Dismissable: d.State.IsTerminal() &&
d.State != store.StateDeleted && d.State != store.StateCancelled, d.State != store.StateDeleted && d.State != store.StateCancelled,
} }
+74 -21
View File
@@ -18,6 +18,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"unicode/utf8" "unicode/utf8"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -52,6 +53,9 @@ type Reader interface {
// LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из // LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из
// загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке). // загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке).
LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error) LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error)
// ListDeletableDownloads — загрузки, разрешённые к полному удалению, без
// постраничной выдачи (страница группового удаления показывает их разом).
ListDeletableDownloads(ctx context.Context) ([]store.Download, error)
} }
// Deps — зависимости транспорта. // Deps — зависимости транспорта.
@@ -113,16 +117,22 @@ func NewRouter(d Deps) (http.Handler, error) {
// Веб-UI. // Веб-UI.
r.Get("/", s.handleIndex) r.Get("/", s.handleIndex)
r.Get("/download/{id}", s.handleDownload) r.Get("/download/{id}", s.handleDownload)
// Групповое удаление: выбор → подтверждение → исполнение. Отдельная
// страница, потому что живая перерисовка списка стёрла бы выбор человека.
r.Get("/delete", s.handleBulkDeletePage)
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера). // Партиалы телеметрии без потребителя в новой разметке: оставлены гасителями
// вкладок, отрисованных прошлой версией (см. handleFragProgress).
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress) r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding) r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
// Карточка целиком: самополлинг catched до перехода в downloading (бейдж, // Карточка целиком — тик самообновления списка: пока задача наблюдаема,
// имя и появившийся прогресс обновляются без перезагрузки). // карточка приносит текущее состояние без перезагрузки страницы.
r.Get("/fragments/downloads/{id}/card", s.handleFragCard) r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
// Тело ревью для поллинга recognizing (htmx-своп до готового плана). // Тело ревью для поллинга recognizing (htmx-своп до готового плана).
r.Get("/fragments/downloads/{id}/review", s.handleFragReview) r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
r.Post("/ui/downloads", s.handleUIAdd) r.Post("/ui/downloads", s.handleUIAdd)
r.Post("/ui/delete/confirm", s.handleBulkDeleteConfirm)
r.Post("/ui/delete", s.handleBulkDelete)
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel) r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry) r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
@@ -205,8 +215,9 @@ type downloadView struct {
State string State string
Error string Error string
Terminal bool Terminal bool
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг IsDownloading bool // активная загрузка → живой прогресс-бар
SelfPoll bool // catched → карточка сама опрашивает себя до перехода SelfPoll bool // задача наблюдаема → карточка сама опрашивает себя
PollEvery string // интервал самообновления карточки (pollFast/pollSlow)
Progress progressView // живой прогресс (заполняется в handleIndex из снимка) Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
Reviewable bool // review/deferred — есть экран ревью Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку Undoable bool // done — можно откатить раскладку
@@ -311,10 +322,11 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
} }
// buildCardView собирает представление карточки списка из доменных данных и // buildCardView собирает представление карточки списка из доменных данных и
// живого снимка. Общий для полной страницы (handleIndex) и htmx-свопа карточки // живого снимка. Общий для полной страницы (handleIndex), тика самообновления
// после действия (renderCardFragment): чтобы htmx-ветка не дублировала обвязку // (handleFragCard) и htmx-свопа после действия (renderCardFragment): чтобы
// (рейтинг/размер/прогресс). Для retry→downloading карточка обязана нести // htmx-ветки не дублировали обвязку (рейтинг/размер/прогресс). Живые цифры едут
// прогресс-поллер — поэтому Progress заполняется здесь. // вместе с карточкой — своего опроса у блока прогресса нет, поэтому Progress
// заполняется здесь на каждом пути.
func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView { func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView {
v := s.toView(d, now) v := s.toView(d, now)
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг // Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
@@ -432,7 +444,9 @@ func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
res, err := s.deps.Ingestor.Ingest(r.Context(), req) res, err := s.deps.Ingestor.Ingest(r.Context(), req)
if err != nil { if err != nil {
redirectErr(w, r, userErr(r, err, res.DownloadID)) // Нулевой Result на любом пути ошибки — контракт ingest.Ingest;
// корреляционный ключ веб-формы, как и REST, — request_id.
redirectErr(w, r, userErr(r, err, ""))
return return
} }
if res.Deduplicated { if res.Deduplicated {
@@ -492,7 +506,7 @@ func (s *server) surfaceAction(w http.ResponseWriter, r *http.Request, id string
func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) { func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
d, err := s.deps.Reader.GetDownload(r.Context(), id) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragActionErr(w, err, id, "card-"+id)
return return
} }
sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id}) sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
@@ -512,7 +526,7 @@ func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id s
func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) { func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragActionErr(w, err, id, "download-main")
return return
} }
v := s.buildDownloadView(id, rd) v := s.buildDownloadView(id, rd)
@@ -585,10 +599,10 @@ func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
} }
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context}) res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
if err != nil { if err != nil {
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit) — // Приём на любом пути ошибки возвращает нулевой Result (контракт
// тогда коррелируем по download_id, иначе (ранний разбор источника) по // ingest.Ingest) — идентификатора загрузки тут нет и быть не может,
// request_id. // коррелируем по request_id.
s.apiErr(w, r, err, res.DownloadID) s.apiErr(w, r, err, "")
return return
} }
status := http.StatusCreated status := http.StatusCreated
@@ -659,7 +673,8 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
Error: d.ErrorMsg.String, Error: d.ErrorMsg.String,
Terminal: d.State.IsTerminal(), Terminal: d.State.IsTerminal(),
IsDownloading: d.State == store.StateDownloading, IsDownloading: d.State == store.StateDownloading,
SelfPoll: d.State == store.StateCatched, SelfPoll: d.State.IsObservable(),
PollEvery: pollSlow,
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone, Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
@@ -667,6 +682,10 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
Retriable: d.State == store.StateFailed || d.State == store.StateStuck, Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Note: desyncNote(d.State), Note: desyncNote(d.State),
} }
// Быстрый интервал — только там, где на поверхности бегут цифры качания.
if v.IsDownloading {
v.PollEvery = pollFast
}
// Дата добавления в карточке — всегда (source_added_at → фолбэк created_at, // Дата добавления в карточке — всегда (source_added_at → фолбэк created_at,
// как в порядке списка); неразбираемое время просто опускаем. // как в порядке списка); неразбираемое время просто опускаем.
if t, ok := addedTime(d); ok { if t, ok := addedTime(d); ok {
@@ -682,12 +701,36 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
// несколько строк заголовка. // несколько строк заголовка.
func downloadTitle(d store.Download) string { func downloadTitle(d store.Download) string {
if d.DisplayName != "" { if d.DisplayName != "" {
return d.DisplayName return displaySafe(d.DisplayName)
} }
if d.RecTitle.Valid && d.RecTitle.String != "" { if d.RecTitle.Valid && d.RecTitle.String != "" {
return d.RecTitle.String return displaySafe(d.RecTitle.String)
} }
return shorten(oneLine(d.SourceRef), 80) return displaySafe(shorten(oneLine(d.SourceRef), 80))
}
// displaySafe готовит заголовок к показу: снимает управляющие символы
// направления письма (`unicode.Bidi_Control` — с ними строка читается не в том
// порядке, в каком хранится) и заменяет управляющие пробелом (перевод строки в
// заголовке склеил бы слова). Имя раздачи — недоверенный вход, а
// `html/template` экранирует разметку, но эти символы пропускает. Цена высока
// на экране подтверждения группового удаления: там поимённое чтение заголовков
// и есть предохранитель необратимой операции.
//
// Снимается ровно этот класс, не весь `unicode.Cf`: в `Cf` лежат и ZWJ/ZWNJ,
// без которых рассыпаются составные эмодзи и меняется написание персидских и
// индийских имён. Чистим на показе, а не на записи — хранение дословное, и
// поиск по списку идёт по сохранённому имени.
func displaySafe(s string) string {
return strings.Map(func(r rune) rune {
if unicode.Is(unicode.Bidi_Control, r) {
return -1
}
if r < 0x20 || r == 0x7f {
return ' '
}
return r
}, s)
} }
// oneLine схлопывает переводы строк и лишние пробелы — сырой источник в // oneLine схлопывает переводы строк и лишние пробелы — сырой источник в
@@ -768,7 +811,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и // (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и
// некорректный ввод команды (worker.ErrInvalidInput) → // некорректный ввод команды (worker.ErrInvalidInput) →
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели // 400; недокачанный источник (worker.ErrNotReady), коллизия цели
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее // (layout.ErrCollision), непомещающееся целевое имя (layout.ErrNameTooLong) и
// конфликт состояния (worker.ErrConflict) → 409; прочее
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только // → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
// сообщение + корреляционный ключ. // сообщение + корреляционный ключ.
func classifyErr(err error) (int, string) { func classifyErr(err error) (int, string) {
@@ -791,6 +835,10 @@ func classifyErr(err error) (int, string) {
// Целевой путь уже занят: задача штатно ушла в review с причиной — // Целевой путь уже занят: задача штатно ушла в review с причиной —
// это не сбой, а требующий разбора конфликт. // это не сбой, а требующий разбора конфликт.
return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью" return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью"
case errors.Is(err, layout.ErrNameTooLong):
// Целевое имя не помещается в файловую систему: задача штатно ушла в
// review, где название правится подсказкой. Не сбой сервера.
return http.StatusConflict, "целевое имя слишком длинное, задача отправлена в ревью"
case errors.Is(err, worker.ErrConflict): case errors.Is(err, worker.ErrConflict):
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой. // Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
return http.StatusConflict, "действие недоступно в текущем состоянии" return http.StatusConflict, "действие недоступно в текущем состоянии"
@@ -799,6 +847,11 @@ func classifyErr(err error) (int, string) {
return http.StatusBadRequest, errManualSource.Error() return http.StatusBadRequest, errManualSource.Error()
case errors.Is(err, errInvalidCandidate): case errors.Is(err, errInvalidCandidate):
return http.StatusBadRequest, errInvalidCandidate.Error() return http.StatusBadRequest, errInvalidCandidate.Error()
case errors.Is(err, errBatchEmpty), errors.Is(err, errBatchTooLarge),
errors.Is(err, errBatchBadID):
// Отказы разбора пачки группового удаления — промах ввода, не сбой:
// текст sentinel'а показывается человеку как есть.
return http.StatusBadRequest, err.Error()
default: default:
return http.StatusInternalServerError, "внутренняя ошибка" return http.StatusInternalServerError, "внутренняя ошибка"
} }
+25
View File
@@ -94,6 +94,9 @@ func (f *fakeReader) GetDownload(_ context.Context, id string) (*store.Download,
func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[string]int64, error) { func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[string]int64, error) {
return nil, nil return nil, nil
} }
func (f *fakeReader) ListDeletableDownloads(_ context.Context) ([]store.Download, error) {
return nil, nil
}
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server { func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
t.Helper() t.Helper()
@@ -1098,3 +1101,25 @@ func TestRerecognize(t *testing.T) {
t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid) t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid)
} }
} }
func TestAPICommandNameTooLong(t *testing.T) {
// Непомещающееся целевое имя (layout.ErrNameTooLong) → 409 (штатно ушло в
// review), не 500: это конфликт, требующий разбора, а не сбой сервера.
// Наружу — нейтральное сообщение, сырой текст ошибки остаётся в логах.
cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrNameTooLong)}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409", resp.StatusCode)
}
var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got)
if msg, _ := got["error"].(string); !strings.Contains(msg, "слишком длинное") {
t.Errorf("error = %q, want содержащее «слишком длинное»", msg)
}
}
+93 -25
View File
@@ -18,27 +18,43 @@ type LiveStatus interface {
Live(infohash string) (worker.Live, bool) Live(infohash string) (worker.Live, bool)
} }
// Интервалы самообновления поверхностей (значение hx-trigger="every …").
//
// - pollFast — поверхность с живыми цифрами качания (карточка в downloading).
// Равен [worker].poll_interval: воркер снимает телеметрию раз в 5 с, и
// опрашивать чаще значит возвращать тот же кадр (docs/database.md).
// - pollSlow — все прочие наблюдаемые поверхности, включая страницу
// /download/{id} в любом состоянии: там меняется только состояние, а сборка
// страницы считает предпросмотр раскладки и ходит в ФС.
const (
pollFast = "5s"
pollSlow = "15s"
)
// noLive — заглушка на случай, когда источник телеметрии не подключён // noLive — заглушка на случай, когда источник телеметрии не подключён
// (Deps.Live == nil): живых данных нет, UI деградирует штатно. // (Deps.Live == nil): живых данных нет, UI деградирует штатно.
type noLive struct{} type noLive struct{}
func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false } func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }
// progressView — живой прогресс активной загрузки (для карточки и фрагмента // progressView — живой прогресс активной загрузки (вложенный блок карточки).
// /progress). Active управляется store-состоянием (downloading), а не qbt: // Active управляется store-состоянием (downloading), а не qbt: вне downloading
// когда задача покидает downloading, фрагмент возвращается без поллинга. // скорость и ETA смысла не имеют, и блок не рисуется. Своего опроса блок не
// ведёт — цифры приезжают с тиком карточки (web-ui, «Самообновление живой
// задачи»).
type progressView struct { type progressView struct {
ID string ID string
Active bool // store-состояние downloading → показываем бар и поллим Active bool // store-состояние downloading → показываем бар
Has bool // есть данные снимка Has bool // есть данные снимка
Percent int Percent int
DlSpeed string DlSpeed string
ETA string ETA string
} }
// seedingView — живая статистика раздачи (для страницы и фрагмента /seeding). // seedingView — живая статистика раздачи (секция страницы загрузки).
// Has истинно только если торрент сидирует и данные есть — иначе секция // Has истинно только если торрент сидирует и данные есть — иначе секция
// деградирует (пустой контейнер, поллинг прекращается). // деградирует (пустой контейнер). Своего опроса секция не ведёт: она лежит
// внутри свопаемой области страницы, и её цифры приезжают с тиком страницы.
type seedingView struct { type seedingView struct {
ID string ID string
Has bool Has bool
@@ -79,7 +95,13 @@ func buildSeeding(id string, l worker.Live, ok bool) seedingView {
return v return v
} }
// handleFragProgress отдаёт партиал живого прогресса карточки (htmx-поллинг). // handleFragProgress отдаёт партиал живого прогресса карточки.
//
// Потребителя в новой разметке у маршрута нет: блок прогресса едет с тиком
// карточки. Маршрут оставлен гасителем вкладок, отрисованных прошлой версией:
// htmx не свопит 4xx/5xx и не снимает hx-trigger, поэтому удалённый маршрут
// заставил бы старую вкладку стучать бесконечно, а партиал без поллинга гасит
// её первым же тиком. Убирается отдельной уборкой после деплоя.
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) { func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
@@ -88,7 +110,7 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
} }
d, err := s.deps.Reader.GetDownload(r.Context(), id) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragTickErr(w, err, id, "dl-live-"+id)
return return
} }
active := d.State == store.StateDownloading active := d.State == store.StateDownloading
@@ -96,10 +118,11 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
s.render(w, "progress", buildProgress(id, active, l, ok)) s.render(w, "progress", buildProgress(id, active, l, ok))
} }
// handleFragCard отдаёт карточку списка целиком (htmx-самополлинг catched): // handleFragCard отдаёт карточку списка целиком — это тик её самообновления.
// пока загрузка в catched, карточка опрашивает себя и по переходе в downloading // Пока задача наблюдаема (State.IsObservable), карточка опрашивает себя и на
// приносит обновлённый бейдж/имя и прогресс-поллер; выйдя из catched, свежая // каждом тике приносит текущее состояние целиком: бейдж, заголовок, набор
// карточка уже не несёт самополлинга — цикл завершается сам. // действий и живые цифры. Перестала быть наблюдаемой — свежая карточка уже не
// несёт самополлинга, и цикл завершается сам.
func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) { func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
@@ -108,15 +131,25 @@ func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
} }
d, err := s.deps.Reader.GetDownload(r.Context(), id) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragTickErr(w, err, id, "card-"+id)
return return
} }
// layoutSize 0: у catched раскладки нет; в downloading размер берётся из // Размер читаем так же, как своповый путь действия: самообновление
// живого снимка внутри buildCardView. // обслуживает и состояния с разложенными файлами, и подмена известного
s.render(w, "card", s.buildCardView(*d, store.Now(), 0)) // размера прочерком была бы потерей поля полного рендера.
sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
if err != nil {
// WARN, а не ERROR: тик повторится сам (docs/conventions/logging.md).
s.deps.Logger.Warn("layout sizes", "download_id", id, "error", err)
sizes = nil // деградируем: размер уедет в фолбэк, тик не падает
}
s.render(w, "card", s.buildCardView(*d, store.Now(), sizes[id]))
} }
// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг). // handleFragSeeding отдаёт партиал секции «Раздача».
//
// Как и у прогресса, потребителя в новой разметке нет: секция едет с тиком
// страницы. Маршрут оставлен гасителем старых вкладок — см. handleFragProgress.
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) { func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
@@ -125,22 +158,57 @@ func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
} }
d, err := s.deps.Reader.GetDownload(r.Context(), id) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragTickErr(w, err, id, "seeding-"+id)
return return
} }
l, ok := s.liveFor(*d) l, ok := s.liveFor(*d)
s.render(w, "seeding", buildSeeding(id, l, ok)) s.render(w, "seeding", buildSeeding(id, l, ok))
} }
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound → // fragTickErr — отказ чтения на повторяющемся тике самообновления: 200 и
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе). // фрагмент, который объясняет положение дел и НЕ несёт самообновления.
func (s *server) fragErr(w http.ResponseWriter, err error, id string) { //
if errors.Is(err, store.ErrNotFound) { // Статусом ошибки отвечать нельзя: htmx не свопит DOM на 4xx/5xx, поэтому
http.Error(w, "не найдено", http.StatusNotFound) // поверхность осталась бы прежней навсегда (человек не отличит «ничего не
return // изменилось» от «сервер не отвечает»), а её опрос продолжался бы бесконечно —
// при затяжном отказе хранилища это поток записей в журнал с каждой открытой
// вкладки. Фрагмент без hx-* завершает цикл сам (web-ui, «Самообновление живой
// задачи»).
//
// Уровень WARN, а не ERROR: у тика есть штатный ретрай — следующий тик повторит
// (docs/conventions/logging.md, «Ошибки»).
func (s *server) fragTickErr(w http.ResponseWriter, err error, id, rootID string) {
s.fragNote(w, err, id, rootID, false)
} }
// fragActionErr — отказ чтения на разовом действии человека: тот же
// самозавершающийся фрагмент, но ERROR: ретрая у действия нет.
func (s *server) fragActionErr(w http.ResponseWriter, err error, id, rootID string) {
s.fragNote(w, err, id, rootID, true)
}
// fragNote отдаёт фрагмент отказа с корнем rootID. Корень обязателен и
// приходит от вызывающего: htmx свопит outerHTML, и фрагмент без целевого id
// снёс бы узел вместе с якорем — следующее действие и поллер цели не нашли бы
// (docs/conventions/web-ui.md, «Единый источник разметки»).
func (s *server) fragNote(w http.ResponseWriter, err error, id, rootID string, oneShot bool) {
text := "задача не найдена — обновите страницу"
if !errors.Is(err, store.ErrNotFound) {
if oneShot {
s.deps.Logger.Error("live fragment", "download_id", id, "error", err) s.deps.Logger.Error("live fragment", "download_id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError) } else {
s.deps.Logger.Warn("live fragment", "download_id", id, "error", err)
}
text = "не удалось обновить — обновите страницу"
}
s.render(w, "frag_note", fragNoteView{RootID: rootID, Text: text})
}
// fragNoteView — самозавершающийся фрагмент отказа (см. fragNote). RootID —
// id узла, который фрагмент собой заменяет.
type fragNoteView struct {
RootID string
Text string
} }
// --- форматирование телеметрии --- // --- форматирование телеметрии ---
+246 -17
View File
@@ -1,6 +1,7 @@
package httpapi package httpapi
import ( import (
"errors"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
@@ -9,8 +10,8 @@ import (
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
) )
// TestFragProgressDownloading: активная задача → фрагмент с прогрессом, // TestFragProgressDownloading: маршрут прогресса остался гасителем старых
// значениями снимка и атрибутами htmx-поллинга. // вкладок — отдаёт цифры снимка и НЕ несёт собственного опроса.
func TestFragProgressDownloading(t *testing.T) { func TestFragProgressDownloading(t *testing.T) {
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDownloading} dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDownloading}
lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}} lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
@@ -21,25 +22,80 @@ func TestFragProgressDownloading(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
body := rr.Body.String() body := rr.Body.String()
for _, want := range []string{`hx-trigger="every 3s"`, "/fragments/downloads/" + testULID + "/progress", "width:42%", "42%"} { for _, want := range []string{"width:42%", "42%"} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body) t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body)
} }
} }
if strings.Contains(body, "hx-trigger") {
t.Errorf("партиал прогресса всё ещё опрашивает сервер сам:\n%s", body)
}
} }
// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading, // TestProgressBlockHiddenOutsideDownloading: вне downloading блок живых цифр не
// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается). // рисуется вовсе — скорость и ETA там смысла не имеют. Проверка на отсутствие
func TestFragProgressStopsWhenNotDownloading(t *testing.T) { // hx-trigger сюда не годится: партиал не несёт его ни при каком входе, и такой
// тест был бы зелёным независимо от логики.
func TestProgressBlockHiddenOutsideDownloading(t *testing.T) {
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDone} dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDone}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{}) lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.9, DlSpeed: 6400000, ETA: 720}}}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
rr := get(t, h, "/fragments/downloads/"+testULID+"/progress") rr := get(t, h, "/fragments/downloads/"+testULID+"/progress")
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
if body := rr.Body.String(); strings.Contains(body, "hx-trigger") { body := rr.Body.String()
t.Errorf("завершённая задача всё ещё поллит:\n%s", body) for _, unwanted := range []string{`class="progress"`, "dl-stats", "90%"} {
if strings.Contains(body, unwanted) {
t.Errorf("вне downloading блок цифр не должен рисоваться, есть %q:\n%s", unwanted, body)
}
}
}
// TestFragErrKeepsSwapRoot: фрагмент отказа несёт корневой id того узла, который
// он собой заменяет. Иначе своп уносит якорь поверхности: экран ревью или
// страница загрузки теряют цель для всех своих действий и мертвы до перезагрузки
// (docs/conventions/web-ui.md, «Единый источник разметки»).
func TestFragErrKeepsSwapRoot(t *testing.T) {
cases := []struct{ path, root string }{
{"/fragments/downloads/" + testULID + "/card", `id="card-` + testULID + `"`},
{"/fragments/downloads/" + testULID + "/progress", `id="dl-live-` + testULID + `"`},
{"/fragments/downloads/" + testULID + "/seeding", `id="seeding-` + testULID + `"`},
{"/fragments/downloads/" + testULID + "/review", `id="review-main"`},
}
h := testRouterLive(t, stubReader{getErr: errors.New("db is gone")}, stubReviewer{}, stubLive{})
for _, c := range cases {
rr := get(t, h, c.path)
if rr.Code != http.StatusOK {
t.Errorf("%s: status = %d, want 200", c.path, rr.Code)
continue
}
if body := rr.Body.String(); !strings.Contains(body, c.root) {
t.Errorf("%s: фрагмент отказа без корня %s:\n%s", c.path, c.root, body)
}
}
}
// TestPageTickFailureSelfTerminates: тик страницы идёт тем же маршрутом, что и
// навигация, поэтому отказ на htmx-пути обязан отвечать 200 и фрагментом с
// корнем #download-main без hx-*; навигационный GET по-прежнему получает статус.
func TestPageTickFailureSelfTerminates(t *testing.T) {
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
rr := getHTMX(t, h, "/download/"+testULID)
if rr.Code != http.StatusOK {
t.Fatalf("тик страницы: status = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="download-main"`) {
t.Errorf("фрагмент отказа страницы без корня #download-main:\n%s", body)
}
if strings.Contains(body, "hx-trigger") {
t.Errorf("фрагмент отказа страницы не самозавершается:\n%s", body)
}
if rr := get(t, h, "/download/"+testULID); rr.Code != http.StatusNotFound {
t.Errorf("навигационный GET: status = %d, want 404", rr.Code)
} }
} }
@@ -57,11 +113,15 @@ func TestFragSeeding(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
body := rr.Body.String() body := rr.Body.String()
for _, want := range []string{"Раздача", "2.41", "38 / 14", `hx-trigger="every 3s"`} { for _, want := range []string{"Раздача", "2.41", "38 / 14"} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("фрагмент раздачи не содержит %q\n%s", want, body) t.Errorf("фрагмент раздачи не содержит %q\n%s", want, body)
} }
} }
// Секция лежит внутри свопаемой области страницы — своего опроса не ведёт.
if strings.Contains(body, "hx-trigger") {
t.Errorf("секция раздачи всё ещё опрашивает сервер сама:\n%s", body)
}
} }
// TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет. // TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет.
@@ -80,7 +140,8 @@ func TestFragSeedingDegrades(t *testing.T) {
} }
// TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже // TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже
// в первом кадре (значения снимка) и атрибуты поллинга. // в первом кадре (значения снимка), а опрашивает себя сама карточка — один
// поллер на поверхность, во вложенном блоке прогресса его нет.
func TestIndexCardShowsLiveProgress(t *testing.T) { func TestIndexCardShowsLiveProgress(t *testing.T) {
dl := store.Download{ID: testULID, SourceRef: "The.Bear.S03", Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih3", Kind: store.HashV1}}, State: store.StateDownloading} dl := store.Download{ID: testULID, SourceRef: "The.Bear.S03", Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih3", Kind: store.HashV1}}, State: store.StateDownloading}
lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}} lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}}
@@ -91,21 +152,189 @@ func TestIndexCardShowsLiveProgress(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
body := rr.Body.String() body := rr.Body.String()
for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/" + testULID + "/progress"} { for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/" + testULID + "/card"} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("карточка без живого прогресса: нет %q", want) t.Errorf("карточка без живого прогресса: нет %q", want)
} }
} }
if strings.Contains(body, "/fragments/downloads/"+testULID+"/progress") {
t.Errorf("вложенный блок прогресса опрашивает себя сам:\n%s", body)
}
if n := strings.Count(body, `hx-trigger="every`); n != 1 {
t.Errorf("объявлений самообновления на карточке = %d, want 1\n%s", n, body)
}
} }
// TestFragNotFound: фрагмент несуществующей задачи → 404. // TestFragTickOnMissingDownload: тик по исчезнувшей задаче отвечает 200 и
func TestFragNotFound(t *testing.T) { // фрагментом без hx-* — htmx не свопит 4xx/5xx, поэтому отказ статусом оставил
// бы карточку прежней навсегда, а опрос — бесконечным.
func TestFragTickOnMissingDownload(t *testing.T) {
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{}) h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
if rr := get(t, h, "/fragments/downloads/01arz3ndektsv4rrffq69g5fff/progress"); rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code) rr := get(t, h, "/fragments/downloads/01arz3ndektsv4rrffq69g5fff/card")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
} }
// Невалидный id → 404 без похода в БД. body := rr.Body.String()
if !strings.Contains(body, "не найдена") {
t.Errorf("фрагмент не объясняет отказ тика:\n%s", body)
}
if strings.Contains(body, "hx-trigger") || strings.Contains(body, "hx-get") {
t.Errorf("фрагмент отказа не самозавершается:\n%s", body)
}
}
// TestFragInvalidID: невалидный id → 404 без похода в БД (это не тик живой
// поверхности, а запрос по несуществующему адресу).
func TestFragInvalidID(t *testing.T) {
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound { if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound {
t.Fatalf("status(invalid id) = %d, want 404", rr.Code) t.Fatalf("status(invalid id) = %d, want 404", rr.Code)
} }
} }
// TestFragTickOnStoreFailure: отказ хранилища на тике — тоже 200 и
// самозавершающийся фрагмент, но с другим текстом: «не найдена» здесь соврало бы.
func TestFragTickOnStoreFailure(t *testing.T) {
h := testRouterLive(t, stubReader{getErr: errors.New("db is gone")}, stubReviewer{}, stubLive{})
rr := get(t, h, "/fragments/downloads/"+testULID+"/card")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "не удалось обновить") {
t.Errorf("отказ хранилища выдан за пропажу задачи:\n%s", body)
}
if strings.Contains(body, "hx-trigger") {
t.Errorf("фрагмент отказа не самозавершается:\n%s", body)
}
}
// TestFragCardSurvivesSizeFailure: отказ чтения размеров не роняет тик —
// карточка деградирует на прочерк, а не на пустой ответ.
func TestFragCardSurvivesSizeFailure(t *testing.T) {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateReview}
rd := stubReader{one: &dl, sizesErr: errors.New("db is busy")}
h := testRouterLive(t, rd, stubReviewer{}, stubLive{})
rr := get(t, h, "/fragments/downloads/"+testULID+"/card")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if body := rr.Body.String(); !strings.Contains(body, "Ревью →") {
t.Errorf("тик не пережил отказ чтения размеров:\n%s", body)
}
}
// TestCardSelfPollFollowsObservability: карточка опрашивает себя, пока задача
// наблюдаема, и замолкает, когда двигать её может только человек. failed,
// target_missing и orphaned наблюдаются: их возвращает в поток фоновая сверка.
func TestCardSelfPollFollowsObservability(t *testing.T) {
polling := []store.State{
store.StateCatched, store.StateDownloading, store.StateCompleted,
store.StateRecognizing, store.StateReview, store.StateLinking,
store.StateDeferred, store.StateStuck,
store.StateFailed, store.StateTargetMissing, store.StateOrphaned,
}
silent := []store.State{
store.StateDone, store.StateCancelled, store.StateReverted, store.StateDeleted,
}
for _, st := range polling {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: st}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if !strings.Contains(body, "/fragments/downloads/"+testULID+"/card") {
t.Errorf("%s: наблюдаемая карточка не опрашивает себя:\n%s", st, body)
}
}
for _, st := range silent {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: st}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if strings.Contains(body, "hx-trigger") {
t.Errorf("%s: ненаблюдаемая карточка продолжает опрос:\n%s", st, body)
}
}
}
// TestCardPollInterval: быстрый интервал — только там, где бегут цифры качания.
func TestCardPollInterval(t *testing.T) {
cases := []struct {
state store.State
want string
}{
{store.StateDownloading, `hx-trigger="every ` + pollFast + `"`},
{store.StateReview, `hx-trigger="every ` + pollSlow + `"`},
{store.StateCatched, `hx-trigger="every ` + pollSlow + `"`},
}
for _, c := range cases {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: c.state}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if !strings.Contains(body, c.want) {
t.Errorf("%s: нет %q\n%s", c.state, c.want, body)
}
}
}
// TestFragCardBringsNewStateAndActions: первый ответ фрагмента после смены
// состояния приносит новый бейдж и новый набор действий — ради этого change и
// затевался.
func TestFragCardBringsNewStateAndActions(t *testing.T) {
cases := []struct {
state store.State
want string
}{
{store.StateReview, "Ревью →"},
{store.StateDone, "Откатить"},
}
for _, c := range cases {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: c.state}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if !strings.Contains(body, c.want) {
t.Errorf("%s: фрагмент не принёс действие %q\n%s", c.state, c.want, body)
}
}
}
// TestDownloadPageSelfPoll: страница живёт по тому же правилу наблюдаемости,
// интервал у неё всегда медленный (блока живых цифр качания на ней нет), а
// секция «Раздача» своего опроса не ведёт — один поллер на поверхность.
func TestDownloadPageSelfPoll(t *testing.T) {
seedLive := stubLive{m: map[string]worker.Live{"ihp": {Seeding: true, Progress: 1, Ratio: 2.4, Seeds: 3, Peers: 1}}}
hashes := []store.Infohash{{DownloadID: testULID, Infohash: "ihp", Kind: store.HashV1}}
// Наблюдаемая задача с сидирующей раздачей: ровно одно объявление опроса.
dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateReview, Infohashes: hashes}
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{data: &worker.ReviewData{Download: dl}}, seedLive)
body := get(t, h, "/download/"+testULID).Body.String()
if !strings.Contains(body, `hx-trigger="every `+pollSlow+`"`) {
t.Errorf("страница наблюдаемой задачи без медленного самообновления:\n%s", body)
}
if n := strings.Count(body, `hx-trigger="every`); n != 1 {
t.Errorf("объявлений самообновления на странице = %d, want 1", n)
}
// Ненаблюдаемая задача: страница замолкает.
done := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateDone, Infohashes: hashes}
h = testRouterLive(t, stubReader{one: &done}, stubReviewer{data: &worker.ReviewData{Download: done}}, seedLive)
if body := get(t, h, "/download/"+testULID).Body.String(); strings.Contains(body, `hx-trigger="every`) {
t.Errorf("страница ненаблюдаемой задачи продолжает опрос:\n%s", body)
}
}
// TestFragCardKeepsLayoutSize: самообновление не теряет полей полного рендера —
// размер разложенных файлов при отсутствии раздачи в снимке.
func TestFragCardKeepsLayoutSize(t *testing.T) {
dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateOrphaned}
rd := stubReader{one: &dl, sizes: map[string]int64{testULID: 3 << 30}}
h := testRouterLive(t, rd, stubReviewer{}, stubLive{})
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
if !strings.Contains(body, "3.0 ГиБ") {
t.Errorf("фрагмент карточки потерял размер раскладки:\n%s", body)
}
}
+35 -1
View File
@@ -18,19 +18,43 @@ type stubReader struct {
list []store.Download list []store.Download
one *store.Download one *store.Download
sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк) sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк)
getErr error // отказ чтения задачи (не ErrNotFound)
sizesErr error // отказ чтения размеров раскладки
// Для страницы группового удаления: список разрешённых к удалению и
// поштучное чтение по id (страница подтверждения называет каждую поимённо).
deletable []store.Download
deletableErr error
byID map[string]store.Download
} }
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil } func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) { func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
return s.list, len(s.list), nil return s.list, len(s.list), nil
} }
func (s stubReader) GetDownload(context.Context, string) (*store.Download, error) { func (s stubReader) GetDownload(_ context.Context, id string) (*store.Download, error) {
if s.getErr != nil {
return nil, s.getErr
}
if s.byID != nil {
d, ok := s.byID[id]
if !ok {
return nil, store.ErrNotFound
}
return &d, nil
}
if s.one == nil { if s.one == nil {
return nil, store.ErrNotFound return nil, store.ErrNotFound
} }
return s.one, nil return s.one, nil
} }
func (s stubReader) ListDeletableDownloads(context.Context) ([]store.Download, error) {
return s.deletable, s.deletableErr
}
func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) { func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) {
if s.sizesErr != nil {
return nil, s.sizesErr
}
return s.sizes, nil return s.sizes, nil
} }
@@ -94,6 +118,16 @@ func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
return rr return rr
} }
// getHTMX — тот же GET, но помеченный как htmx-запрос (тик самообновления).
func getHTMX(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("HX-Request", "true")
h.ServeHTTP(rr, req)
return rr
}
// testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат). // testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат).
const testULID = "01arz3ndektsv4rrffq69g5fav" const testULID = "01arz3ndektsv4rrffq69g5fav"
+6 -1
View File
@@ -43,6 +43,7 @@ type reviewView struct {
State string State string
Error string // из ?err= Error string // из ?err=
StateError string // error_msg загрузки (напр. причина коллизии) StateError string // error_msg загрузки (напр. причина коллизии)
PreviewError string // почему предпросмотр не построился, посчитано на показе
MediaType string MediaType string
IsSeries bool IsSeries bool
Title string Title string
@@ -113,6 +114,10 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
State: string(rd.Download.State), State: string(rd.Download.State),
Error: errMsg, Error: errMsg,
StateError: rd.Download.ErrorMsg.String, StateError: rd.Download.ErrorMsg.String,
// Причина пустого предпросмотра считается на показе и потому всегда про
// текущий план; error_msg остался от последнего перехода и после смены
// источника уже не про него.
PreviewError: rd.PreviewError,
Hints: rd.Hints, Hints: rd.Hints,
} }
if rec := rd.Recognition; rec != nil { if rec := rd.Recognition; rec != nil {
@@ -450,7 +455,7 @@ func (s *server) handleFragReview(w http.ResponseWriter, r *http.Request) {
} }
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil { if err != nil {
s.fragErr(w, err, id) s.fragTickErr(w, err, id, "review-main")
return return
} }
s.render(w, "review_main", buildReviewView(id, rd, "")) s.render(w, "review_main", buildReviewView(id, rd, ""))
+50
View File
@@ -2,6 +2,9 @@ package httpapi_test
import ( import (
"bytes" "bytes"
"encoding/json"
"errors"
"fmt"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
@@ -85,3 +88,50 @@ func TestUIAddUrlencoded(t *testing.T) {
t.Errorf("urlencoded source не проброшен: %q", ing.lastReq.Source) t.Errorf("urlencoded source не проброшен: %q", ing.lastReq.Source)
} }
} }
// Отказ приёма на HTTP-границе: идентификатора загрузки нет (контракт
// ingest.Ingest — нулевой Result на любом пути ошибки), поэтому корреляционным
// ключом остаётся request_id запроса. Проверяются оба HTTP-транспорта: REST
// отдаёт ключ полем тела, веб-форма — текстом флеш-сообщения в редиректе.
func TestIngestErrorCorrelatesByRequestID(t *testing.T) {
t.Run("REST", func(t *testing.T) {
ing := &fakeIngestor{err: fmt.Errorf("ingest: create download: %w", errors.New("boom"))}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
strings.NewReader(`{"source":"magnet:?xt=urn:btih:abc"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if s, _ := body["request_id"].(string); s == "" {
t.Errorf("в теле отказа нет request_id: %v", body)
}
if _, ok := body["download_id"]; ok {
t.Errorf("в теле отказа обещан download_id: %v", body)
}
})
t.Run("веб-форма", func(t *testing.T) {
ing := &fakeIngestor{err: fmt.Errorf("ingest: create download: %w", errors.New("boom"))}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads",
url.Values{"source": {"magnet:?xt=urn:btih:abc"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
loc := resp.Header.Get("Location")
if !strings.Contains(loc, "request_id%3D") && !strings.Contains(loc, "request_id=") {
t.Errorf("в сообщении отказа нет request_id: %q", loc)
}
if strings.Contains(loc, "download_id") {
t.Errorf("в сообщении отказа обещан download_id: %q", loc)
}
})
}
+25 -6
View File
@@ -66,7 +66,9 @@ type Request struct {
Context string // подсказка для распознавания (опц.) Context string // подсказка для распознавания (опц.)
} }
// Result — итог приёма. // Result — итог приёма. При ненулевой ошибке Ingest возвращает НУЛЕВОЙ Result:
// идентификатор загрузки, хеши, состояние и признак дедупликации не
// публикуются (см. Ingest).
type Result struct { type Result struct {
DownloadID string DownloadID string
Infohashes []string // все хеши источника (гибридный magnet: v1 и v2, v1 первым) Infohashes []string // все хеши источника (гибридный magnet: v1 и v2, v1 первым)
@@ -78,7 +80,24 @@ type Result struct {
// полей ссылки, дедуплицирует по активной задаче, иначе сохраняет загрузку в // полей ссылки, дедуплицирует по активной задаче, иначе сохраняет загрузку в
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод // `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
// имени выполняет worker (см. download-tracking). // имени выполняет worker (см. download-tracking).
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { //
// Контракт: на ЛЮБОМ пути ошибки возвращается нулевой Result. Приём не создаёт
// наблюдаемых последствий раньше, чем способен вернуть успех, а всё, что может
// отказать после заведения загрузки, делает worker. Транспорты на это
// опираются и не обещают идентификатора, которого нет: HTTP коррелирует отказ
// по request_id, Telegram — ключа не даёт (см. ingest-спеку, требование
// «Результат приёма при ошибке пуст»).
//
// Гарантия структурная — обнуление в одном defer, а не аккуратность каждой
// ветки возврата: перечень веток растёт, и именно расхождение перечня с
// комментариями транспортов породило исходный дефект.
func (s *Service) Ingest(ctx context.Context, req Request) (res Result, err error) {
defer func() {
if err != nil {
res = Result{}
}
}()
src, err := s.parse(req) src, err := s.parse(req)
if err != nil { if err != nil {
// Невалидный источник — норма (адресат не команда, а пользователь, и он // Невалидный источник — норма (адресат не команда, а пользователь, и он
@@ -179,10 +198,10 @@ func (s *Service) parse(req Request) (parsedSource, error) {
} }
// SourceRef — человекочитаемый референс (имя раздачи), НЕ адрес // SourceRef — человекочитаемый референс (имя раздачи), НЕ адрес
// добавления: torrent добавляется байтами (см. worker), не по SourceRef. // добавления: torrent добавляется байтами (см. worker), не по SourceRef.
// Фолбек на имя файла, если у раздачи нет содержательного имени // Фолбек на имя файла, если у раздачи нет содержательного имени:
// (пустое или NoName-сентинел "-"). // вырожденное значение отбросил разборщик, здесь остаётся пустота.
ref := strings.TrimSpace(info.DisplayName) ref := info.DisplayName
if ref == "" || ref == "-" { if ref == "" {
ref = strings.TrimSpace(req.TorrentName) ref = strings.TrimSpace(req.TorrentName)
} }
return parsedSource{ return parsedSource{
+38
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"log/slog" "log/slog"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -24,13 +25,22 @@ type fakeStore struct {
upgradeID string // downloadID последнего вызова UpgradeCatchedMagnetToTorrent upgradeID string // downloadID последнего вызова UpgradeCatchedMagnetToTorrent
upgradeBlob []byte // байты, переданные в апгрейд upgradeBlob []byte // байты, переданные в апгрейд
upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent
lookupErr error // отказ хранилища на дедуп-чеке
createErr error // отказ хранилища на заведении загрузки
} }
func (f *fakeStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) { func (f *fakeStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
if f.lookupErr != nil {
return nil, f.lookupErr
}
return f.active, nil return f.active, nil
} }
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) { func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
if f.createErr != nil {
return nil, f.createErr
}
if f.active != nil { if f.active != nil {
return f.active, nil return f.active, nil
} }
@@ -300,3 +310,31 @@ func TestIngestRejectsNonMagnet(t *testing.T) {
t.Error("не должно быть записи задачи") t.Error("не должно быть записи задачи")
} }
} }
// Контракт приёма: на ЛЮБОМ пути ошибки транспорту возвращается НУЛЕВОЙ Result.
// Транспорты на это опираются и не обещают идентификатора, которого нет
// (см. ingest-спеку, «Результат приёма при ошибке пуст»). Сравниваем результат
// с нулевым значением ЦЕЛИКОМ, а не по полю DownloadID: следующая ветвь отказа
// может заполнить другое поле.
func TestIngestReturnsZeroResultOnEveryErrorPath(t *testing.T) {
boom := errors.New("boom")
for _, tc := range []struct {
name string
fs *fakeStore
req Request
}{
{"невалидный источник", &fakeStore{}, Request{Source: "не magnet и не torrent"}},
{"сбой хранилища на дедуп-чеке", &fakeStore{lookupErr: boom}, Request{Source: sampleMagnet}},
{"сбой хранилища на заведении", &fakeStore{createErr: boom}, Request{Source: sampleMagnet}},
} {
t.Run(tc.name, func(t *testing.T) {
res, err := newService(tc.fs).Ingest(context.Background(), tc.req)
if err == nil {
t.Fatal("ожидалась ошибка")
}
if !reflect.DeepEqual(res, Result{}) {
t.Errorf("Result = %+v, want нулевой", res)
}
})
}
}
+15 -3
View File
@@ -134,10 +134,20 @@ func TestIngestTorrentTooLarge(t *testing.T) {
} }
} }
// У раздачи без имени source_ref берётся из имени файла (фолбек). // У раздачи без содержательного имени source_ref берётся из имени файла.
// Случая два, и они разные: раздача БЕЗ поля name (BestName() == "") и
// раздача, объявившая вырожденное `-` (metainfo.NoName) — второй нормализует
// разборщик, приём про него уже не знает.
func TestIngestTorrentNameFallback(t *testing.T) { func TestIngestTorrentNameFallback(t *testing.T) {
// Info без name → BestName() == "" → фолбек на TorrentName. for _, tc := range []struct {
info := metainfo.Info{Name: "", Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)} name string
infoName string
}{
{"без поля name", ""},
{"вырожденное имя", metainfo.NoName},
} {
t.Run(tc.name, func(t *testing.T) {
info := metainfo.Info{Name: tc.infoName, Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
infoBytes, err := bencode.Marshal(info) infoBytes, err := bencode.Marshal(info)
if err != nil { if err != nil {
t.Fatalf("marshal: %v", err) t.Fatalf("marshal: %v", err)
@@ -157,6 +167,8 @@ func TestIngestTorrentNameFallback(t *testing.T) {
if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" { if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" {
t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef) t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef)
} }
})
}
} }
func TestIngestTorrentInvalid(t *testing.T) { func TestIngestTorrentInvalid(t *testing.T) {
+10
View File
@@ -171,6 +171,11 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
if !underRoot(root, dst) { if !underRoot(root, dst) {
return nil, fmt.Errorf("layout: target %q is outside library %q (file %q)", dst, root, f.Src) return nil, fmt.Errorf("layout: target %q is outside library %q (file %q)", dst, root, f.Src)
} }
// Длина проверяется ПОСЛЕ песочницы: путь, вышедший за библиотеку, —
// находка безопасности, и подменять её косметической причиной нельзя.
if err := checkComponentLengths(root, dst); err != nil {
return nil, err
}
links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind}) links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind})
} }
if len(links) == 0 { if len(links) == 0 {
@@ -267,6 +272,11 @@ type Result struct {
// ErrCollision — цель существует и это другой файл (нужен review). // ErrCollision — цель существует и это другой файл (нужен review).
var ErrCollision = errors.New("layout: target collision") var ErrCollision = errors.New("layout: target collision")
// ErrNameTooLong — компонент целевого пути длиннее предела длины имени
// (maxComponentBytes). Проверяется в BuildLinks, до первой операции с ФС:
// задача уходит в review с доменной причиной, а не в failed с текстом ядра.
var ErrNameTooLong = errors.New("layout: имя не помещается")
// ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных // ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных
// (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не // (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не
// единственный файл (см. state-reconciliation, инвариант безопасного Undo). // единственный файл (см. state-reconciliation, инвариант безопасного Undo).
+47
View File
@@ -6,6 +6,53 @@ import (
"strings" "strings"
) )
// maxComponentBytes — предел длины одного компонента целевого пути в БАЙТАХ
// UTF-8, а не в символах: ядро меряет NAME_MAX в байтах, и кириллическое
// название упирается в предел вдвое раньше латинского той же длины в знаках.
// Значение — NAME_MAX у ext4/xfs/btrfs; у ядра оно не выясняется, потому что
// раскладка обязана отказать до обращения к диску (см. spec file-layout).
// Цена обеих сторон: на ФС с меньшим пределом (часть зашифрованных) имя пройдёт
// проверку и упрётся в ядро — останется сегодняшний failed; на ФС с бо́льшим мы
// откажем строже, чем нужно. Лечение — правка этой константы, а не настройка:
// значение, которое некому выставить осознанно, не гибкость.
const maxComponentBytes = 255
// checkComponentLengths проверяет, что каждый компонент пути dst ПОД корнем
// root помещается в maxComponentBytes. Корень не проверяется: его каталоги задаёт
// оператор, и жаловаться на них раскладка не вправе. Возвращает ошибку,
// обёртывающую ErrNameTooLong и называющую непомещающийся компонент и его длину.
// Чистая функция: к диску не обращается.
func checkComponentLengths(root, dst string) error {
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(dst))
if err != nil {
return fmt.Errorf("layout: relative target %q: %w", dst, err)
}
for c := range strings.SplitSeq(rel, string(filepath.Separator)) {
if len(c) > maxComponentBytes {
return fmt.Errorf("%w: %q — %d байт при пределе %d",
ErrNameTooLong, shorten(c), len(c), maxComponentBytes)
}
}
return nil
}
// errNameSample — сколько рун непомещающегося имени показать в тексте ошибки.
// Текст уезжает в error_msg, а оттуда в баннер ревью и в карточку Telegram:
// имя целиком (а оно по условию длиннее 255 байт) заняло бы там весь экран.
// Точную длину несёт число рядом, поэтому образца хватает, чтобы узнать имя.
const errNameSample = 40
// shorten оставляет от имени начало и конец, выкидывая середину. Режет по рунам:
// обрыв посреди многобайтовой буквы дал бы в сообщении мусор.
func shorten(s string) string {
r := []rune(s)
if len(r) <= errNameSample {
return s
}
head := errNameSample / 2
return string(r[:head]) + "…" + string(r[len(r)-head:])
}
// sanitizeComponent чистит один компонент пути (имя папки/файла): убирает // sanitizeComponent чистит один компонент пути (имя папки/файла): убирает
// разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает // разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает
// пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри // пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри
+250
View File
@@ -0,0 +1,250 @@
package layout
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// countDirEntries считает всё, что появилось под корнем библиотеки: проверка
// длины обязана отказать ДО первой операции с ФС, поэтому пусто — это часть
// утверждения, а не гигиена.
func countDirEntries(t *testing.T, root string) int {
t.Helper()
n := 0
err := filepath.WalkDir(root, func(p string, _ os.DirEntry, err error) error {
if err != nil {
return err
}
if p != root {
n++
}
return nil
})
if err != nil {
t.Fatal(err)
}
return n
}
// 2.1 Имя файла длиннее предела: отказ целиком, ни одного каталога на диске.
func TestBuildLinks_FileNameTooLong(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
// Название впритык под папку, но имя файла = база + ".mkv".
title := strings.Repeat("a", maxComponentBytes-len(" (1999)"))
plan := Plan{
Type: Movie, Title: title, Year: 1999,
Files: []PlanFile{{Src: src, Role: RoleMain}},
}
links, err := f.l.BuildLinks(plan)
if !errors.Is(err, ErrNameTooLong) {
t.Fatalf("err = %v, want ErrNameTooLong", err)
}
if links != nil {
t.Errorf("links = %v, want nil (отказ целиком)", links)
}
if n := countDirEntries(t, f.movies); n != 0 {
t.Errorf("под movies появилось %d записей, ожидалось 0 (проверка до операций с ФС)", n)
}
}
// 2.2 Папка тайтла длиннее предела, хотя имя файла бы поместилось.
func TestBuildLinks_FolderNameTooLong(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
// База помещается, но provider-тег выталкивает папку за предел; имя файла
// тега не несёт и остаётся коротким.
tag := "tmdbid-693134"
title := strings.Repeat("b", maxComponentBytes-len(" (1999)")-len(" [")-len(tag)-len("]"))
plan := Plan{
Type: Movie, Title: title, Year: 1999, ProviderTag: tag,
Files: []PlanFile{{Src: src, Role: RoleMain}},
}
if _, err := f.l.BuildLinks(plan); err != nil {
t.Fatalf("контроль: имя ровно в предел должно проходить, got %v", err)
}
plan.Title = title + "c" // +1 байт — папка перестаёт помещаться
_, err := f.l.BuildLinks(plan)
if !errors.Is(err, ErrNameTooLong) {
t.Fatalf("err = %v, want ErrNameTooLong", err)
}
if n := countDirEntries(t, f.movies); n != 0 {
t.Errorf("под movies появилось %d записей, ожидалось 0", n)
}
}
// 2.3 Предел меряется в БАЙТАХ, а не в рунах: кириллица упирается вдвое раньше.
// Заодно граница 255/256.
func TestBuildLinks_LimitIsBytesNotRunes(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
build := func(title string) error {
_, err := f.l.BuildLinks(Plan{
Type: Movie, Title: title,
Files: []PlanFile{{Src: src, Role: RoleMain}},
})
return err
}
const ext = ".mkv"
// Граница ровно на 255 байтах имени файла.
fit := strings.Repeat("a", maxComponentBytes-len(ext))
if err := build(fit); err != nil {
t.Fatalf("255 байт должны помещаться, got %v", err)
}
if err := build(fit + "a"); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("256 байт: err = %v, want ErrNameTooLong", err)
}
// Столько же ЗНАКОВ кириллицей — вдвое больше байтов, отказ.
cyr := strings.Repeat("я", maxComponentBytes-len(ext))
if err := build(cyr); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("кириллица той же длины в знаках: err = %v, want ErrNameTooLong "+
"(предел меряется в байтах)", err)
}
// Граница кириллицей — тоже по байтам. 125 букв = 250 байт, плюс ".mkv" = 254:
// помещается. Ещё одна буква даёт 256 — не помещается.
cyrFit := strings.Repeat("я", (maxComponentBytes-len(ext))/2)
if err := build(cyrFit); err != nil {
t.Fatalf("254 байта кириллицей должны помещаться, got %v", err)
}
if err := build(cyrFit + "я"); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("256 байт кириллицей: err = %v, want ErrNameTooLong", err)
}
}
// 2.4 Приоритет: путь и вне библиотеки, и слишком длинный → отказ называет
// выход за библиотеку, а не длину. Иначе находка безопасности спрячется за
// косметической причиной.
func TestBuildLinks_OutsideLibraryBeatsTooLong(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
// Санитизация режет разделители, поэтому traversal через Title недостижим;
// проверяем сам порядок на checkComponentLengths напрямую: путь вне корня
// до неё не доходит, а внутри корня — доходит.
outside := filepath.Join(filepath.Dir(f.movies), strings.Repeat("z", 300))
if underRoot(f.movies, outside) {
t.Fatal("подготовка теста неверна: путь обязан быть вне корня")
}
_, err := f.l.BuildLinks(Plan{
Type: Movie, Title: strings.Repeat("z", 300),
Files: []PlanFile{{Src: src, Role: RoleMain}},
})
if !errors.Is(err, ErrNameTooLong) {
t.Fatalf("внутри корня длинное имя даёт ErrNameTooLong, got %v", err)
}
// Прямая сверка порядка в BuildLinks: underRoot стоит раньше и его отказ
// формулируется своим текстом (см. layout.go).
if err := checkComponentLengths(f.movies, outside); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("checkComponentLengths вне корня: %v", err)
}
}
// 2.7 Название не усекается: на непомещающемся входе ссылок нет вовсе, а не
// возвращена усечённая. Усечение схлопнуло бы два разных названия в один каталог.
func TestBuildLinks_NoTruncation(t *testing.T) {
f := newFixture(t)
src := f.srcFile(t, "long/movie.mkv", "x")
links, err := f.l.BuildLinks(Plan{
Type: Movie, Title: strings.Repeat("d", 400),
Files: []PlanFile{{Src: src, Role: RoleMain}},
})
if err == nil {
t.Fatalf("ожидался отказ, получено %d ссылок", len(links))
}
if len(links) != 0 {
t.Errorf("вернулось %d ссылок — усечение недопустимо", len(links))
}
}
// 2.8 Мерится финальный компонент, а не название: суффикс субтитров
// дописывается после и обязан учитываться.
func TestBuildLinks_SubtitleSuffixCounted(t *testing.T) {
f := newFixture(t)
video := f.srcFile(t, "long/ep.mkv", "x")
sub := f.srcFile(t, "long/ep.ru.srt", "y")
// База подобрана так, что видеофайл помещается, а субтитр с ".ru.forced.srt" —
// уже нет: разница ровно в длине суффикса.
const stem = " S01E02"
title := strings.Repeat("e", maxComponentBytes-len(stem)-len(".mkv"))
plan := Plan{
Type: Series, Title: title,
Files: []PlanFile{
{Src: video, Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
},
}
if _, err := f.l.BuildLinks(plan); err != nil {
t.Fatalf("видеофайл впритык должен проходить, got %v", err)
}
plan.Files = append(plan.Files, PlanFile{
Src: sub, Role: RoleSubtitle, Season: intp(1), Episode: intp(2),
Lang: "ru", Flags: []string{"forced"},
})
if _, err := f.l.BuildLinks(plan); !errors.Is(err, ErrNameTooLong) {
t.Fatalf("субтитр с суффиксом обязан упереться: err = %v", err)
}
}
// 2.9 Вырожденные входы проверку не роняют.
func TestCheckComponentLengths_Degenerate(t *testing.T) {
root := "/srv/media/movies"
cases := []string{
root + "/",
root + "/ ",
root + "/" + string([]byte{0xff, 0xfe, 0xfd}), // невалидный UTF-8
root + "/" + strings.Repeat("x", 1<<16),
root,
}
for _, dst := range cases {
// Требование одно: не паниковать и вернуть значение.
_ = checkComponentLengths(root, dst)
}
if err := checkComponentLengths(root, root+"/"+strings.Repeat("x", 1<<16)); !errors.Is(err, ErrNameTooLong) {
t.Error("очень длинный компонент обязан давать ErrNameTooLong")
}
}
// Корень библиотеки под проверку не попадает: его каталоги задаёт оператор.
func TestCheckComponentLengths_RootNotChecked(t *testing.T) {
root := "/srv/" + strings.Repeat("r", 300)
if err := checkComponentLengths(root, root+"/Dune (2024)/Dune (2024).mkv"); err != nil {
t.Errorf("длинный корень не должен считаться отказом: %v", err)
}
}
// Нерасчислимый относительный путь (корень абсолютный, цель относительная) —
// не отказ по длине, а отдельная ошибка: путать их нельзя, иначе диагноз соврёт.
func TestCheckComponentLengths_UnrelatablePath(t *testing.T) {
err := checkComponentLengths("/srv/media/movies", "relative/path.mkv")
if err == nil {
t.Fatal("want error for unrelatable path")
}
if errors.Is(err, ErrNameTooLong) {
t.Errorf("нерасчислимый путь не должен выдаваться за отказ по длине: %v", err)
}
}
// shorten держит текст причины коротким: имя в сообщении усечено серединой, а
// точную длину несёт число рядом. Короткое имя не трогается.
func TestShorten(t *testing.T) {
short := strings.Repeat("a", errNameSample)
if got := shorten(short); got != short {
t.Errorf("имя в предел образца не должно меняться: %q", got)
}
long := strings.Repeat("я", 200)
got := shorten(long)
if r := []rune(got); len(r) != errNameSample+1 { // +1 — многоточие
t.Errorf("длина образца = %d рун, want %d", len(r), errNameSample+1)
}
if !strings.Contains(got, "…") {
t.Errorf("усечённое имя должно нести многоточие: %q", got)
}
// Режем по рунам: обрыв посреди буквы дал бы мусор вместо кириллицы.
if strings.ContainsRune(got, '') {
t.Errorf("усечение разорвало руну: %q", got)
}
}
+32 -2
View File
@@ -52,12 +52,23 @@ func TestIntegration_TVMaze(t *testing.T) {
// пропускается; включается ключом: // пропускается; включается ключом:
// //
// TVDB_API_KEY=... go test ./internal/metadata/ -run Integration -v // TVDB_API_KEY=... go test ./internal/metadata/ -run Integration -v
//
// Он же — единственный оракул на форму блока переводов в выдаче поиска:
// docs/research/tvdb-search-translations.md записан по документации, живым
// прогоном не подтверждён. Прогон под русской локалью печатает Title и
// OriginalTitle: у movie 131155 «Нэчжа» ожидается в Title, а иероглифический
// primary name — в OriginalTitle. Совпадение Title с OriginalTitle у иноязычной
// записи означает, что перевод не доехал и предположение о форме неверно.
func TestIntegration_TVDB(t *testing.T) { func TestIntegration_TVDB(t *testing.T) {
key := os.Getenv("TVDB_API_KEY") key := os.Getenv("TVDB_API_KEY")
if key == "" { if key == "" {
t.Skip("set TVDB_API_KEY to run") t.Skip("set TVDB_API_KEY to run")
} }
c, err := metadata.NewTVDB(metadata.TVDBConfig{APIKey: key, Timeout: 20 * time.Second}, nil) c, err := metadata.NewTVDB(metadata.TVDBConfig{
APIKey: key,
Timeout: 20 * time.Second,
Language: "ru",
}, nil)
if err != nil { if err != nil {
t.Fatalf("NewTVDB: %v", err) t.Fatalf("NewTVDB: %v", err)
} }
@@ -73,11 +84,30 @@ func TestIntegration_TVDB(t *testing.T) {
if i >= 5 { if i >= 5 {
break break
} }
t.Logf(" id=%s title=%q year=%d", cd.ID, cd.Title, cd.Year) t.Logf(" id=%s title=%q original=%q year=%d", cd.ID, cd.Title, cd.OriginalTitle, cd.Year)
} }
if len(cands) == 0 { if len(cands) == 0 {
t.Fatal("ожидался хотя бы один кандидат для Fargo") t.Fatal("ожидался хотя бы один кандидат для Fargo")
} }
if cands[0].OriginalTitle == "" {
t.Error("OriginalTitle пуст: primary name в кандидат не доехал")
}
if cands[0].Title == "" {
t.Error("Title пуст: фолбэк на primary name не сработал")
}
// Иноязычная запись — тот случай, ради которого задача заводилась.
// Расхождение Title и OriginalTitle подтверждает форму блока переводов.
film, err := c.Search(ctx, metadata.Query{Type: metadata.Movie, Title: "Ne Zha", Year: 2019})
if err != nil {
t.Fatalf("Search(Ne Zha): %v", err)
}
for i, cd := range film {
if i >= 5 {
break
}
t.Logf(" ne zha: id=%s title=%q original=%q year=%d", cd.ID, cd.Title, cd.OriginalTitle, cd.Year)
}
// Берём первого с непустым id и тянем число серий по сезонам. // Берём первого с непустым id и тянем число серий по сезонам.
id := cands[0].ID id := cands[0].ID
+106 -3
View File
@@ -25,14 +25,35 @@ type TVDBConfig struct {
Proxy string Proxy string
Timeout time.Duration Timeout time.Duration
BaseURL string // пусто → api4.thetvdb.com; задаётся в тестах BaseURL string // пусто → api4.thetvdb.com; задаётся в тестах
// Language — абстрактный код языка вывода ("ru" | "en"); диалект локали TVDB
// (трёхбуквенный код) выводит сам клиент (tvdbLocale). Знание диалекта живёт
// здесь, у провайдера, который на нём говорит, а не в общем слое конфига.
Language string
}
// tvdbLocale переводит абстрактный код языка вывода в код языка TVDB
// (трёхбуквенный, ISO 639-2). Тотальна: непокрытый вход (пусто, неизвестный код)
// → eng, чтобы поиск перевода никогда не шёл по пустому ключу (тогда фолбэк
// срабатывал бы всегда и молча). Множество кодов задаёт config.validate
// ({ru, en}); при добавлении кода — синхронно добавь ветку здесь.
func tvdbLocale(lang string) string {
switch lang {
case "ru":
return "rus"
default:
return "eng"
}
} }
// TVDB — клиент TheTVDB (API v4). Токен получается логином по apikey и // TVDB — клиент TheTVDB (API v4). Токен получается логином по apikey и
// кэшируется; при 401 выполняется повторный логин. Формы ответов сверены с // кэшируется; при 401 выполняется повторный логин. Формы ответов сверены с
// живым API v4 (см. integration_test.go). // живым API v4 (см. integration_test.go) — кроме блока переводов в выдаче
// поиска: он взят из публичной документации и живым прогоном не подтверждён
// (docs/research/tvdb-search-translations.md).
type TVDB struct { type TVDB struct {
apiKey string apiKey string
baseURL string baseURL string
language string
hc *http.Client hc *http.Client
log *slog.Logger log *slog.Logger
@@ -56,7 +77,13 @@ func NewTVDB(cfg TVDBConfig, logger *slog.Logger) (*TVDB, error) {
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
return &TVDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil return &TVDB{
apiKey: cfg.APIKey,
baseURL: strings.TrimRight(base, "/"),
language: tvdbLocale(cfg.Language),
hc: hc,
log: logger,
}, nil
} }
func (t *TVDB) Name() string { return "tvdb" } func (t *TVDB) Name() string { return "tvdb" }
@@ -148,10 +175,66 @@ type tvdbSearchResp struct {
TVDBID string `json:"tvdb_id"` TVDBID string `json:"tvdb_id"`
Name string `json:"name"` Name string `json:"name"`
Year string `json:"year"` Year string `json:"year"`
// Translations — карта «код языка → название». Тип сырой намеренно:
// строгий тип дал бы косметическому полю право провалить json.Unmarshal
// всего ответа и убить кандидатов, которые сейчас приезжают нормально.
// Форма поля живым API не подтверждена (см. docs/research/).
Translations json.RawMessage `json:"translations"`
} `json:"data"` } `json:"data"`
} }
// translatedName достаёт из сырого блока переводов название на языке lang.
//
// Второе значение — НЕ «перевода нет», а «форма ответа та, что мы предположили»:
// нёс ли блок хоть один ключ вида трёхбуквенного кода языка. Разбор блока таким
// признаком быть не может: json.Unmarshal успешно кладёт в карту и `null`, и
// `{}`, и словарь двухбуквенных кодов, а именно двухбуквенные коды — главный
// названный риск этого изменения (docs/research/tvdb-search-translations.md).
// Признак «разобралось» промолчал бы ровно там, где нужен сигнал.
//
// Негодная форма блока при этом не ошибка разбора ответа, а тотальный фолбэк на
// primary name: косметическое поле не получает права уронить выдачу поиска.
//
// Ключ ищется регистронезависимо — молчаливый фолбэк из-за регистра неотличим от
// «перевода нет». Выбор среди совпавших детерминирован: порядок обхода карты в Go
// случаен, а EqualFold совпадает и с `RUS`, и с юникод-эквивалентами простого
// case-folding, так что «первый попавшийся» давал бы разное имя папки от прогона
// к прогону на одном и том же ответе.
func translatedName(raw json.RawMessage, lang string) (name string, sawLangKeys bool) {
if len(raw) == 0 {
return "", false
}
var m map[string]string
if err := json.Unmarshal(raw, &m); err != nil || len(m) == 0 {
// `null` и `{}` разбираются без ошибки, но полезной нагрузки не несут —
// от отсутствия блока они неотличимы, и признаком формы быть не могут.
return "", false
}
best := ""
for k := range m {
if len(k) == 3 {
sawLangKeys = true
}
if !strings.EqualFold(k, lang) {
continue
}
switch {
case best == "", k == lang, best != lang && k < best:
best = k
}
}
if best == "" {
return "", sawLangKeys
}
return strings.TrimSpace(m[best]), sawLangKeys
}
// Search ищет сериал/фильм по названию и году. // Search ищет сериал/фильм по названию и году.
//
// Параметр языка в запрос НЕ передаётся: у /search TVDB он фильтрует выдачу по
// основному языку записи, а не выбирает перевод, и сузил бы результат ровно на
// иноязычных записях. Локаль работает только на разборе ответа
// (openspec/specs/metadata-match, docs/research/tvdb-search-translations.md).
func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) { func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
typ := "series" typ := "series"
if q.Type == Movie { if q.Type == Movie {
@@ -166,19 +249,39 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
return nil, fmt.Errorf("tvdb search: %w", err) return nil, fmt.Errorf("tvdb search: %w", err)
} }
out := make([]Candidate, 0, len(resp.Data)) out := make([]Candidate, 0, len(resp.Data))
sawLangKeys := false
for _, r := range resp.Data { for _, r := range resp.Data {
if r.TVDBID == "" { if r.TVDBID == "" {
continue continue
} }
year, _ := strconv.Atoi(r.Year) year, _ := strconv.Atoi(r.Year)
title, sawKeys := translatedName(r.Translations, t.language)
sawLangKeys = sawLangKeys || sawKeys
if title == "" {
title = r.Name // фолбэк тотален: перевода нет, он пуст или блок негоден
}
out = append(out, Candidate{ out = append(out, Candidate{
Provider: "tvdb", Provider: "tvdb",
ID: r.TVDBID, ID: r.TVDBID,
Title: r.Name, Title: title,
OriginalTitle: r.Name,
Year: year, Year: year,
URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID, URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID,
}) })
} }
// Во всей выдаче не встретилось ни одного трёхбуквенного кода языка —
// подозрение, что форма ответа не та, что записана в разведке. Штатное
// «перевода на этот язык нет» под условие не подпадает: там коды есть, просто
// нужного среди них нет. Один чекпоинт на операцию.
//
// Уровень WARN, а не DEBUG: это не рутина, а «наше предположение о внешнем
// контракте, возможно, неверно» (docs/conventions/logging.md — «команде, может
// стать проблемой»). DEBUG в проде выключен, а деградация здесь молчаливая:
// названия тихо уедут в фолбэк, и заметить это будет нечем. Ср. соседнее
// решение про refresh токена выше — там DEBUG осознан, случай ровно обратный.
if len(out) > 0 && !sawLangKeys {
logctx.FromOr(ctx, t.log).Warn("tvdb search returned no language-coded translations", "tvdb_locale", t.language)
}
return out, nil return out, nil
} }
+186 -2
View File
@@ -1,10 +1,15 @@
package metadata package metadata
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
) )
@@ -38,7 +43,8 @@ func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server {
if r.URL.Query().Get("type") != "series" || r.URL.Query().Get("query") != "Fargo" { if r.URL.Query().Get("type") != "series" || r.URL.Query().Get("query") != "Fargo" {
t.Errorf("query = %v", r.URL.Query()) t.Errorf("query = %v", r.URL.Query())
} }
_, _ = w.Write([]byte(`{"data":[{"tvdb_id":"269613","name":"Fargo","year":"2014"}]}`)) _, _ = w.Write([]byte(`{"data":[{"tvdb_id":"269613","name":"Fargo","year":"2014",
"translations":{"rus":"Фарго","eng":"Fargo"}}]}`))
})) }))
mux.HandleFunc("/series/269613/extended", authed(func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/series/269613/extended", authed(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":{"episodes":[ _, _ = w.Write([]byte(`{"data":{"episodes":[
@@ -52,13 +58,191 @@ func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server {
func newTVDB(t *testing.T, url string) *TVDB { func newTVDB(t *testing.T, url string) *TVDB {
t.Helper() t.Helper()
c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url}, nil) return newTVDBLang(t, url, "")
}
func newTVDBLang(t *testing.T, url, lang string) *TVDB {
t.Helper()
c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url, Language: lang}, nil)
if err != nil { if err != nil {
t.Fatalf("NewTVDB: %v", err) t.Fatalf("NewTVDB: %v", err)
} }
return c return c
} }
// searchStand — стенд с одной записью поиска: тело ответа задаётся тестом,
// строка запроса и число обращений к /search записываются для проверок.
type searchStand struct {
srv *httptest.Server
mu sync.Mutex
queries []string
searches atomic.Int32
}
func (s *searchStand) recorded() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.queries...)
}
func newSearchStand(t *testing.T, body string) *searchStand {
t.Helper()
s := &searchStand{}
mux := http.NewServeMux()
mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":{"token":"tok"}}`))
})
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
s.searches.Add(1)
s.mu.Lock()
s.queries = append(s.queries, r.URL.RawQuery)
s.mu.Unlock()
_, _ = w.Write([]byte(body))
})
s.srv = httptest.NewServer(mux)
t.Cleanup(s.srv.Close)
return s
}
// Локализованное название кандидата: перевод, фолбэк во всех его формах и
// OriginalTitle, который равен primary name всегда.
func TestTVDB_SearchTranslations(t *testing.T) {
const primary = "哪吒之魔童降世"
cases := []struct {
name string
lang string
record string
wantTitle string
}{
{"перевод есть", "ru", `"translations":{"rus":"Нэчжа","eng":"Ne Zha"}`, "Нэчжа"},
{"перевода на язык нет", "ru", `"translations":{"eng":"Ne Zha"}`, primary},
{"перевод из пробелов", "ru", `"translations":{"rus":" "}`, primary},
{"ключ в другом регистре", "ru", `"translations":{"RUS":"Нэчжа"}`, "Нэчжа"},
{"блока переводов нет", "ru", `"year":"2019"`, primary},
{"блок пустой", "ru", `"translations":{}`, primary},
{"блок не карта", "ru", `"translations":["Нэчжа"]`, primary},
{"блок null", "ru", `"translations":null`, primary},
{"язык по умолчанию — eng", "", `"translations":{"rus":"Нэчжа","eng":"Ne Zha"}`, "Ne Zha"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := `{"data":[{"tvdb_id":"131155","name":"` + primary + `","year":"2019",` + tc.record + `}]}`
stand := newSearchStand(t, body)
got, err := newTVDBLang(t, stand.srv.URL, tc.lang).
Search(context.Background(), Query{Type: Movie, Title: "Ne Zha"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 1 {
t.Fatalf("кандидатов = %d, want 1 (негодный перевод не должен ронять выдачу)", len(got))
}
if got[0].Title != tc.wantTitle {
t.Errorf("Title = %q, want %q", got[0].Title, tc.wantTitle)
}
if got[0].OriginalTitle != primary {
t.Errorf("OriginalTitle = %q, want primary name %q", got[0].OriginalTitle, primary)
}
if n := stand.searches.Load(); n != 1 {
t.Errorf("обращений к /search = %d, want 1 (лимит ключа не растёт)", n)
}
})
}
}
// Сигнал «форма ответа не та, что записана в разведке». Он единственный, кто
// отличает неверное предположение о чужом API от штатного «перевода нет», —
// поэтому проверяется поимённо, а не через покрытие.
func TestTVDB_SignalOnUnexpectedTranslationForm(t *testing.T) {
cases := []struct {
name string
record string
wantSignal bool
}{
{"двухбуквенные коды", `"translations":{"ru":"Дюна","en":"Dune"}`, true},
{"блок null", `"translations":null`, true},
{"блок пустой", `"translations":{}`, true},
{"блока нет", `"year":"2021"`, true},
{"блок не карта", `"translations":["Дюна"]`, true},
{"вложенные объекты", `"translations":{"rus":{"name":"Дюна"}}`, true},
// Штатный случай: коды трёхбуквенные, нужного среди них нет — не сигнал.
{"перевода на язык нет", `"translations":{"eng":"Dune"}`, false},
{"перевод есть", `"translations":{"rus":"Дюна","eng":"Dune"}`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stand := newSearchStand(t,
`{"data":[{"tvdb_id":"1","name":"Dune","year":"2021",`+tc.record+`}]}`)
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: stand.srv.URL, Language: "ru"}, log)
if err != nil {
t.Fatalf("NewTVDB: %v", err)
}
got, err := c.Search(context.Background(), Query{Type: Movie, Title: "Dune"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 1 {
t.Fatalf("кандидатов = %d, want 1", len(got))
}
signal := strings.Contains(buf.String(), "no language-coded translations")
if signal != tc.wantSignal {
t.Errorf("сигнал = %v, want %v; лог: %q", signal, tc.wantSignal, buf.String())
}
})
}
}
// Выбор среди EqualFold-совпавших ключей детерминирован: иначе один и тот же
// ответ давал бы разное имя папки от прогона к прогону.
func TestTVDB_TranslationKeyPickIsDeterministic(t *testing.T) {
cases := []struct{ name, block, want string }{
{"точное совпадение сильнее регистра", `{"rus":"Дюна","RUS":"HACK"}`, "Дюна"},
{"без точного — лексикографически меньший", `{"RUS":"A","Rus":"B"}`, "A"},
{"юникод-эквивалент case-folding", `{"rus":"Дюна","ruſ":"HACK"}`, "Дюна"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
for i := 0; i < 200; i++ {
got, _ := translatedName(json.RawMessage(tc.block), "rus")
if got != tc.want {
t.Fatalf("прогон %d: got %q, want %q", i, got, tc.want)
}
}
})
}
}
// Локаль работает только на разборе ответа: параметр языка в запрос не уходит,
// и строка запроса не зависит от настройки.
func TestTVDB_SearchQueryHasNoLanguage(t *testing.T) {
const body = `{"data":[{"tvdb_id":"1","name":"X","year":"2000"}]}`
var got []string
for _, lang := range []string{"ru", "en", ""} {
stand := newSearchStand(t, body)
if _, err := newTVDBLang(t, stand.srv.URL, lang).
Search(context.Background(), Query{Type: Movie, Title: "X", Year: 2000}); err != nil {
t.Fatalf("Search(%q): %v", lang, err)
}
recorded := stand.recorded()
if len(recorded) != 1 {
t.Fatalf("запросов = %d", len(recorded))
}
q, err := url.ParseQuery(recorded[0])
if err != nil {
t.Fatalf("ParseQuery: %v", err)
}
if _, ok := q["language"]; ok {
t.Errorf("language=%q в запросе при lang=%q: параметр сужает выдачу, слать его нельзя",
q.Get("language"), lang)
}
got = append(got, recorded[0])
}
if got[0] != got[1] || got[1] != got[2] {
t.Errorf("строка запроса зависит от языка: %q", got)
}
}
func TestTVDB_SearchAndLoginCached(t *testing.T) { func TestTVDB_SearchAndLoginCached(t *testing.T) {
var logins atomic.Int32 var logins atomic.Int32
srv := fakeTVDB(t, &logins) srv := fakeTVDB(t, &logins)
+8 -2
View File
@@ -20,6 +20,7 @@ import (
"log/slog" "log/slog"
"strconv" "strconv"
"strings" "strings"
"unicode"
"unicode/utf8" "unicode/utf8"
"git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/llm"
@@ -202,13 +203,18 @@ func render(f Fields) string {
return Label(f.Title, f.Director, f.Year, f.SeasonLabel()) return Label(f.Title, f.Director, f.Year, f.SeasonLabel())
} }
// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы. // sanitize убирает управляющие и невидимые символы, переводы строк, схлопывает
// пробелы. Категорию Cf (zero-width, BOM, RLO) снимаем наравне с C0: значения
// приходят из метабазы и из сохранённого контекста, то есть снаружи, а ярлык
// уезжает в карточку Telegram, в шапку веб-UI и в имя раздачи qBittorrent —
// RLO там переворачивает отображение. Это же обещает требование metadata-match:
// режиссёр в плане не чистится именно потому, что его чистит рендер.
func sanitize(s string) string { func sanitize(s string) string {
s = strings.Map(func(r rune) rune { s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || r == '\r' { if r == '\n' || r == '\t' || r == '\r' {
return ' ' return ' '
} }
if r < 0x20 { if r < 0x20 || unicode.Is(unicode.Cf, r) {
return -1 return -1
} }
return r return r
+22
View File
@@ -158,3 +158,25 @@ func TestDeriveNameNilProviderUsesFallback(t *testing.T) {
t.Errorf("DeriveName() = %q (ожидался фолбек без LLM)", got) t.Errorf("DeriveName() = %q (ожидался фолбек без LLM)", got)
} }
} }
// Значения ярлыка приходят снаружи — из credits метабазы и из сохранённого
// контекста. Категория Cf (zero-width, BOM, RLO) обязана сниматься здесь:
// требование metadata-match не чистит режиссёра в плане именно потому, что его
// чистит рендер. RLO в карточке Telegram переворачивает отображение имени.
func TestSanitize_StripsFormatChars(t *testing.T) {
cases := []struct{ in, want string }{
{"Denis\u202eVilleneuve", "DenisVilleneuve"},
{"Denis\u200bVilleneuve", "DenisVilleneuve"},
{"Denis\ufeffVilleneuve", "DenisVilleneuve"},
{"Denis\nVilleneuve", "Denis Villeneuve"},
{"Дени Вильнёв", "Дени Вильнёв"},
}
for _, c := range cases {
if got := sanitize(c.in); got != c.want {
t.Errorf("sanitize(%q) = %q, want %q", c.in, got, c.want)
}
}
if got := Label("Dune", "Denis\u202eVilleneuve", 2021, ""); got != "Dune (DenisVilleneuve, 2021)" {
t.Errorf("Label = %q", got)
}
}
+102
View File
@@ -2,10 +2,13 @@ package qbt
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"git.vakhrushev.me/av/jellybit/internal/logctx"
) )
// fakeQBittorrent — минимальный стенд WebUI API: требует cookie SID, выдаёт // fakeQBittorrent — минимальный стенд WebUI API: требует cookie SID, выдаёт
@@ -238,3 +241,102 @@ func TestLoginFailure(t *testing.T) {
t.Error("ожидалась ошибка логина") t.Error("ожидалась ошибка логина")
} }
} }
// captureHandler собирает записи журнала для проверки их полей. Свой WithAttrs
// нужен обязательно: делегированный встроенному хендлеру вернул бы ЕГО, и
// логгер, собранный через With (а scoped-логгер загрузки собирается именно
// так), перестал бы захватываться.
type captureHandler struct {
attrs []slog.Attr
records *[]slog.Record
}
func (h *captureHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
rec := r.Clone()
rec.AddAttrs(h.attrs...)
*h.records = append(*h.records, rec)
return nil
}
func (h *captureHandler) WithAttrs(as []slog.Attr) slog.Handler {
return &captureHandler{attrs: append(append([]slog.Attr{}, h.attrs...), as...), records: h.records}
}
func (h *captureHandler) WithGroup(string) slog.Handler { return h }
// recordFields сплющивает запись в map «ключ → значение как текст».
func recordFields(r slog.Record) map[string]string {
out := map[string]string{}
r.Attrs(func(a slog.Attr) bool {
out[a.Key] = a.Value.String()
return true
})
return out
}
// Отказ qBittorrent на добавление («Fails.») причины не несёт — единственное,
// что делает такую запись пригодной для разбора, это корреляция с загрузкой.
// Инфохэша клиент не знает и знать не должен (разбор источника — единая точка
// проекта): он берёт scoped-логгер из ctx. Негативная половина: magnet с
// passkey приватного трекера уходит через это же поле формы, и в журнал он
// попасть не должен (инвариант «секреты не попадают в логи»).
func TestAddFailureRecordCarriesScopeAndNoSecret(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v2/auth/login" {
http.SetCookie(w, &http.Cookie{Name: "SID", Value: "token", Path: "/"})
_, _ = w.Write([]byte("Ok."))
return
}
_, _ = w.Write([]byte("Fails."))
}))
defer srv.Close()
// Значение низкоэнтропийное намеренно: высокоэнтропийная фикстура «похожего
// на секрет» вида краснит gitleaks в pre-commit. Различимости хватает —
// тест ищет вхождение этой строки в полях записи.
const passkey = "passkey-must-not-reach-the-journal"
const magnetURL = "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6" +
"&tr=http%3A%2F%2Ftracker.example%2Fann%3Fpasskey%3D" + passkey
var records []slog.Record
log := slog.New(&captureHandler{records: &records}).
With("capability", "review", "download_id", "01JB0000000000000000000000", "infohash",
"541adcff3b6dd5dba7088ea83317d9d6fac331d6")
ctx := logctx.With(context.Background(), log)
err := newClient(t, srv.URL).Add(ctx, AddRequest{URLs: []string{magnetURL}, Category: "jellybit"})
if err == nil {
t.Fatal("ожидался отказ добавления")
}
var failure *slog.Record
for i := range records {
if records[i].Message == "external call failed" {
failure = &records[i]
}
}
if failure == nil {
t.Fatalf("записи об отказе внешнего вызова нет, записей: %d", len(records))
}
f := recordFields(*failure)
if f["download_id"] == "" {
t.Errorf("в записи нет download_id из scoped-логгера: %v", f)
}
if f["infohash"] != "541adcff3b6dd5dba7088ea83317d9d6fac331d6" {
t.Errorf("infohash = %q, want хеш из scoped-логгера", f["infohash"])
}
if f["ext.operation"] != "torrents/add" {
t.Errorf("ext.operation = %q, want torrents/add", f["ext.operation"])
}
// Негативная половина: ни одно поле записи не несёт passkey.
for k, v := range f {
if strings.Contains(v, passkey) {
t.Errorf("passkey утёк в поле %q: %q", k, v)
}
}
if strings.Contains(failure.Message, passkey) {
t.Errorf("passkey утёк в сообщение записи")
}
}
+17 -2
View File
@@ -72,13 +72,17 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
} }
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок). // Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
// Названия чистим на КОПИИ: выбор кандидата человеком — такой же
// путь подтверждения матча, как авто, и закреплённое название так
// же уезжает в имя каталога. Исходный cands не трогаем — по нему
// ниже ищется сильный матч, и сдвигать его гейт задача не заказывала.
for _, c := range cands { for _, c := range cands {
ck := c.Provider + ":" + c.ID ck := c.Provider + ":" + c.ID
if seen[ck] || len(candidates) >= maxCandidates { if seen[ck] || len(candidates) >= maxCandidates {
continue continue
} }
seen[ck] = true seen[ck] = true
candidates = append(candidates, c) candidates = append(candidates, sanitizeCandidate(c))
} }
// Единичный сильный матч ищем у первого подходящего провайдера. // Единичный сильный матч ищем у первого подходящего провайдера.
@@ -139,10 +143,21 @@ func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c meta
} }
} }
prov, pid := CandidateTag(c) prov, pid := CandidateTag(c)
// Каноническое название чистим здесь — в единственной точке сборки Match, и
// уже ПОСЛЕ гейта сильного матча (strongMatches сравнивает по значениям
// провайдера). Дальше по потоку значение считается чистым: и подстановка в
// план, и решение auto/review, и диагностика сухого прогона берут его как
// есть, а не чистят каждый заново.
title := c.Title
if clean := SanitizeTitle(title); clean != title {
logctx.FromOr(ctx, r.log).Debug("metadata match title sanitized",
"before", title, "after", clean)
title = clean
}
return &Match{ return &Match{
Provider: prov, Provider: prov,
ProviderID: pid, ProviderID: pid,
Title: c.Title, Title: title,
Year: c.Year, Year: c.Year,
Director: directorOf(ctx, r.log, p, mt, c.ID), Director: directorOf(ctx, r.log, p, mt, c.ID),
SeasonEpisodeCounts: counts, SeasonEpisodeCounts: counts,
@@ -0,0 +1,220 @@
package recognize
import (
"context"
"testing"
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
// Провенанс набора входов — отчёт триажа ревью tvdb-title-locale (находка 2) и
// раздел «Воспроизведение» задачи metadata-title-sanitize. До правки каждый из
// них давал auto=true и уезжал в имя каталога библиотеки дословно.
const dunePlanResp = `{"type":"movie","title":"Dune","original_title":"Dune","year":2021,
"confidence":0.9,"files":[
{"src":"Dune.2021/movie.mkv","role":"main","season":null,"episode":null}
]}`
func duneInput() Input {
return Input{
Name: "Dune.2021.2160p.BluRay.x265",
Files: []File{{Path: "Dune.2021/movie.mkv", Size: 20 << 30}},
}
}
// recognizeWithMatchTitle прогоняет полный Recognize против базы, единственная
// запись которой названа dbTitle. Год и оригинальное название совпадают с планом,
// поэтому матч подтверждается и все прочие условия авто чисты — судим ровно
// подстановку названия.
func recognizeWithMatchTitle(t *testing.T, dbTitle string) Result {
t.Helper()
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "438631", Title: dbTitle, OriginalTitle: "Dune", Year: 2021},
}}
r := New(&fakeLLM{responses: []string{dunePlanResp}}, []metadata.Provider{p},
Config{MaxRetries: 2}, testLogger())
res, err := r.Recognize(context.Background(), duneInput())
if err != nil {
t.Fatalf("Recognize: %v", err)
}
return res
}
// Четыре входа из «Воспроизведения»: значение в плане обязано совпадать с тем,
// что дал бы санитайзер, а не с тем, что отдала база.
func TestRecognize_MatchTitleSanitized(t *testing.T) {
cases := []struct {
name string
db string
want string
auto bool
about string
}{
{
name: "zero-width внутри слова", db: "Du\u200bne", want: "Dune", auto: true,
about: "невидимка снимается, авто остаётся",
},
{
name: "RLO переворачивает отображение", db: "Dune\u202egnp.mkv",
want: "Dunegnp.mkv", auto: true,
about: "управляющий символ снимается",
},
{
name: "перевод строки", db: "Dune\nHACK", want: "Dune HACK", auto: true,
about: "перевод строки сводится к пробелу",
},
{
name: "кириллический двойник", db: "Dunа", want: "Duna", auto: true,
about: "двойник сворачивается в доминирующий скрипт",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
res := recognizeWithMatchTitle(t, c.db)
if res.Match == nil {
t.Fatalf("матч должен быть подтверждён (%s)", c.about)
}
if res.Plan.Title != c.want {
t.Errorf("plan.Title = %q, want %q", res.Plan.Title, c.want)
}
if res.Plan.Title == c.db {
t.Errorf("значение базы уехало в план дословно: %q", res.Plan.Title)
}
if res.Decision.Auto != c.auto {
t.Errorf("auto = %v, want %v; reasons=%v",
res.Decision.Auto, c.auto, res.Decision.Reasons)
}
})
}
}
// Название, непригодное как имя каталога: пустое после чистки либо голая
// пунктуация. Подстановки не происходит, авто заблокирована причиной, но матч
// остаётся подтверждённым — id и год верны, распознавание не падает.
func TestRecognize_MatchTitleUnusable(t *testing.T) {
for _, db := range []string{"\u200b\u200b\u200b", ".", "...", "-", " . "} {
t.Run(db, func(t *testing.T) {
res := recognizeWithMatchTitle(t, db)
if res.Plan.Title != "Dune" {
t.Errorf("plan.Title = %q, want %q (название распознавания)",
res.Plan.Title, "Dune")
}
if res.Decision.Auto {
t.Error("авто-раскладка должна быть заблокирована")
}
if !hasReason(res.Decision.Reasons, "непригодно как имя каталога") {
t.Errorf("причина не названа: %v", res.Decision.Reasons)
}
if res.Match == nil || res.Match.ProviderID != "438631" {
t.Errorf("матч обязан остаться подтверждённым, got %+v", res.Match)
}
if res.Plan.Year != 2021 {
t.Errorf("год должен быть подставлен, got %d", res.Plan.Year)
}
})
}
}
// Нормальное название: значение базы доезжает как есть, авто разрешена. Это
// защита от регресса на TMDB — санитайзинг на чистом значении не делает ничего.
func TestRecognize_MatchTitleNormalUnchanged(t *testing.T) {
res := recognizeWithMatchTitle(t, "Dune: Part One")
if res.Plan.Title != "Dune: Part One" {
t.Errorf("plan.Title = %q, want %q", res.Plan.Title, "Dune: Part One")
}
if !res.Decision.Auto {
t.Errorf("auto = false, reasons=%v", res.Decision.Reasons)
}
}
// Кандидаты, уходящие в review, очищены: их названия закрепляет человек, и они
// становятся именем каталога так же, как каноническое.
func TestMatchMetadata_CandidatesSanitized(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Du\u200bne", OriginalTitle: "Dun\u200be", Year: 2021},
{Provider: "tmdb", ID: "2", Title: "Dunа Part Two", Year: 2024},
}}
r := recognizerWith(p)
_, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Nothing Matches Here", Year: 1900})
if len(cands) != 2 {
t.Fatalf("candidates = %d, want 2", len(cands))
}
if cands[0].Title != "Dune" || cands[0].OriginalTitle != "Dune" {
t.Errorf("кандидат не очищен: %+v", cands[0])
}
if cands[1].Title != "Duna Part Two" {
t.Errorf("двойник у кандидата не свёрнут: %q", cands[1].Title)
}
}
// Гейт матча не сдвинулся: кандидат, отличающийся от плана только невидимым
// символом внутри слова, сильным не считается. Сравнение идёт по значению
// провайдера — санитизируется только копия, уходящая в review.
func TestMatchMetadata_SanitizeDoesNotMoveGate(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Du\u200bne", Year: 2021},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Dune", Year: 2021})
if m != nil {
t.Errorf("невидимка внутри слова не должна давать сильный матч, got %+v", m)
}
if len(cands) != 1 || cands[0].Title != "Dune" {
t.Errorf("кандидат обязан уйти в review очищенным, got %+v", cands)
}
}
// Список кандидатов не обеднел: после подтверждённого матча у первого провайдера
// остальные продолжают пополнять список для review.
func TestMatchMetadata_CandidatesFromAllProvidersKept(t *testing.T) {
a := &fakeProvider{name: "tmdb", candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Dune", Year: 2021},
}}
b := &fakeProvider{name: "tvdb", candidates: []metadata.Candidate{
{Provider: "tvdb", ID: "9", Title: "Dune Other", Year: 2021},
}}
r := New(&fakeLLM{}, []metadata.Provider{a, b}, Config{}, testLogger())
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Dune", Year: 2021})
if m == nil {
t.Fatal("матч у первого провайдера должен подтвердиться")
}
if len(cands) != 2 {
t.Errorf("candidates = %d, want 2 (кандидаты второго провайдера не теряются)",
len(cands))
}
}
func TestUsableTitle(t *testing.T) {
usable := []string{"Dune", "2001", "Ne Zha", "Брат", "«Дюна»"}
unusable := []string{"", ".", "..", "...", "-", " . ", "—", "!?"}
for _, s := range usable {
if !UsableTitle(s) {
t.Errorf("UsableTitle(%q) = false, want true", s)
}
}
for _, s := range unusable {
if UsableTitle(s) {
t.Errorf("UsableTitle(%q) = true, want false", s)
}
}
}
// Идемпотентность несёт два утверждения сразу: что правка не двигает путей у
// раздач с нормальным названием и что «в плане нет несанитизированных полей»
// вообще проверяемо. Без теста она подразумевалась.
func TestSanitizeTitle_Idempotent(t *testing.T) {
inputs := []string{
"Du\u200bne", "Dune\u202egnp.mkv", "Dune\nHACK", "Dunа",
"Dune: Part One", " spaced out ", "\u200b\u200b\u200b", ".", "-",
"Тёмный рыцарь", "哪吒之魔童降世",
}
for _, in := range inputs {
once := SanitizeTitle(in)
if twice := SanitizeTitle(once); twice != once {
t.Errorf("sanitizeTitle не идемпотентен на %q: %q → %q", in, once, twice)
}
}
}
+20
View File
@@ -153,6 +153,26 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) {
} }
} }
// Цена локализованного названия кандидата: множество осей сравнения выросло с
// одной до двух, и там, где сильный кандидат был один, их может стать двое —
// тогда подтверждённого матча нет и обе записи уходят в review. На этом счётчике
// держится инвариант «авто-раскладка только при подтверждённом матче».
func TestMatchMetadata_TranslationMakesTwoStrong(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tvdb", ID: "1", Title: "Нэчжа", OriginalTitle: "哪吒之魔童降世", Year: 2019},
{Provider: "tvdb", ID: "2", Title: "Ne Zha", OriginalTitle: "Ne Zha", Year: 2019},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Нэчжа", OriginalTitle: "Ne Zha", Year: 2019})
if m != nil {
t.Errorf("двое сильных — подтверждённого матча быть не должно, got %+v", m)
}
if len(cands) != 2 {
t.Errorf("candidates = %d, want 2 (обе записи уходят в review)", len(cands))
}
}
func TestMatchMetadata_MatchByOriginalFirst(t *testing.T) { func TestMatchMetadata_MatchByOriginalFirst(t *testing.T) {
// Реальный кейс: русское релиз-имя, матч по оригинальному названию. // Реальный кейс: русское релиз-имя, матч по оригинальному названию.
// При сильном матче по первому ключу дальнейшие запросы не делаются. // При сильном матче по первому ключу дальнейшие запросы не делаются.
+6
View File
@@ -260,7 +260,13 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
// review, когда единичного сильного матча нет. // review, когда единичного сильного матча нет.
match, candidates := r.matchMetadata(ctx, plan) match, candidates := r.matchMetadata(ctx, plan)
if match != nil { if match != nil {
// Match.Title пришёл санитизированным (buildMatch чистит его в единственной
// точке сборки матча). Здесь остаётся только вопрос пригодности: название,
// не годное как имя каталога, не подставляем вовсе — в плане остаётся
// название распознавания, а decide уводит раздачу в review.
if UsableTitle(match.Title) {
plan.Title = match.Title plan.Title = match.Title
}
if match.Year != 0 { if match.Year != 0 {
plan.Year = match.Year plan.Year = match.Year
} }
+37 -3
View File
@@ -4,6 +4,8 @@ import (
"log/slog" "log/slog"
"strings" "strings"
"unicode" "unicode"
"git.vakhrushev.me/av/jellybit/internal/metadata"
) )
// homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских // homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских
@@ -34,17 +36,49 @@ func init() {
} }
} }
// sanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM: // SanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
// (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и // (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и
// обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем // обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем
// до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов. // до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов.
func sanitizeTitle(s string) string { func SanitizeTitle(s string) string {
s = stripControl(s) s = stripControl(s)
s = collapseSpaces(s) s = collapseSpaces(s)
s = foldHomoglyphs(s) s = foldHomoglyphs(s)
return s return s
} }
// UsableTitle отвечает, годится ли название как компонент пути к файлу. Годится
// то, в чём после санитайзинга осталась хотя бы одна буква или цифра: layout
// снимает разделители и обрезает края от точек и пробелов, поэтому «.», «...»,
// «-» и прочая голая пунктуация схлопываются там в пустое имя каталога, а
// ведущая точка вдобавок делает каталог скрытым для Jellyfin.
//
// Проверять этим предикатом надо УЖЕ санитизированное значение: невидимые
// символы буквами не являются, но и не мешают — их снимает SanitizeTitle.
// Экспортирована вместе с SanitizeTitle, потому что тот же вопрос задаёт worker
// при закреплении выбранного человеком источника: правило одно, домов у него
// быть не должно.
func UsableTitle(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return true
}
}
return false
}
// sanitizeCandidate чистит человекочитаемые названия кандидата метабазы. Зовётся
// на КОПИИ кандидата в момент, когда он уходит из сверки дальше — в список для
// review, в хранилище, на экран и в закрепляемое человеком значение. Значения,
// по которым ищется сильный матч, при этом не меняются: сравнение обязано идти
// по тому, что отдал провайдер (см. metadata-match), иначе кандидат, отличающийся
// от плана невидимым символом, начал бы совпадать там, где прежде уходил в review.
func sanitizeCandidate(c metadata.Candidate) metadata.Candidate {
c.Title = SanitizeTitle(c.Title)
c.OriginalTitle = SanitizeTitle(c.OriginalTitle)
return c
}
// sanitizePlan применяет санитайзинг к человекочитаемым полям плана. files[].src // sanitizePlan применяет санитайзинг к человекочитаемым полям плана. files[].src
// НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и // НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и
// homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review), // homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review),
@@ -57,7 +91,7 @@ func sanitizePlan(p *Plan, log *slog.Logger) {
} }
func sanitizeField(v, field string, log *slog.Logger) string { func sanitizeField(v, field string, log *slog.Logger) string {
clean := sanitizeTitle(v) clean := SanitizeTitle(v)
if clean != v && log != nil { if clean != v && log != nil {
log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean) log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean)
} }
+2 -2
View File
@@ -49,8 +49,8 @@ func TestSanitizeTitle(t *testing.T) {
"The Matrix": "The Matrix", "The Matrix": "The Matrix",
} }
for in, want := range cases { for in, want := range cases {
if got := sanitizeTitle(in); got != want { if got := SanitizeTitle(in); got != want {
t.Errorf("sanitizeTitle(%q) = %q, want %q", in, got, want) t.Errorf("SanitizeTitle(%q) = %q, want %q", in, got, want)
} }
} }
} }
+14 -4
View File
@@ -84,10 +84,10 @@ func validateSchema(p *Plan, in Input) error {
} }
// decide считает решение модели уверенности (см. recognition.md). Авто — // decide считает решение модели уверенности (см. recognition.md). Авто —
// только если выполнено всё: подтверждённый единичный матч в базе; чистая // только если выполнено всё: подтверждённый единичный матч в базе; каноническое
// структурная валидация (для сериала — число серий бьётся с базой); // название матча пригодно как имя каталога; чистая структурная валидация (для
// согласованность с пред-парсом; самооценка LLM не ниже порога. Любая // сериала — число серий бьётся с базой); согласованность с пред-парсом;
// невыполненная — причина ухода в review. // самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review.
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision { func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision {
var reasons []string var reasons []string
@@ -98,6 +98,16 @@ func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold
reasons = append(reasons, "не найдено в базе или несколько кандидатов") reasons = append(reasons, "не найдено в базе или несколько кандидатов")
} }
// Каноническое название базы, непригодное как имя каталога (пустое или без
// единой буквы и цифры), в план не подставлено — там осталось название
// распознавания. Матч при этом верен: id, год и режиссёр на месте, поэтому
// отклоняем не матч, а авто-раскладку. Match.Title уже санитизирован
// (buildMatch), второй раз не чистим: два независимых пересчёта одного
// условия разъедутся на первой же правке одного из них.
if match != nil && !UsableTitle(match.Title) {
reasons = append(reasons, "название из базы непригодно как имя каталога")
}
reasons = append(reasons, structuralWarnings(p)...) reasons = append(reasons, structuralWarnings(p)...)
if match != nil && p.Type == MediaSeries { if match != nil && p.Type == MediaSeries {
+37
View File
@@ -64,6 +64,43 @@ func (s State) IsTerminal() bool {
return slices.Contains(terminalStates, s) return slices.Contains(terminalStates, s)
} }
// selfHealingStates — терминальные состояния, которые фон возвращает в поток
// САМ, без человека: failed (по восстановимым кодам — см. ListRecoverable) и
// состояния рассинхрона, которые сверка переоценивает по реальности (см.
// reconcileDesync). done в перечень не входит сознательно: его переоценка
// означает удаление файлов мимо сервиса — событие редкое, а разложенных задач в
// списке больше всех, и наблюдать за каждой дороже, чем показать новое
// состояние при следующем заходе.
var selfHealingStates = []State{
StateFailed, StateTargetMissing, StateOrphaned,
}
// IsObservable сообщает, может ли состояние задачи измениться без участия
// человека: любое нетерминальное плюс терминальные из selfHealingStates. На
// этом предикате стоит самообновление веб-UI: поверхность обновляет себя, пока
// задача наблюдаема, и замолкает, когда двигать её может только человек (см.
// openspec/specs/web-ui, «Самообновление живой задачи»).
func (s State) IsObservable() bool {
return !s.IsTerminal() || slices.Contains(selfHealingStates, s)
}
// deletableStates — состояния, из которых человеку доступно полное удаление
// (снять библиотечные ссылки + снести раздачу с файлами из qBittorrent).
// Единственный дом перечня: на него смотрят и допуск в ядре (worker.Delete), и
// все транспорты, решающие, показывать действие или нет.
var deletableStates = []State{
StateDone, StateOrphaned, StateTargetMissing,
}
// CanDelete сообщает, допускает ли состояние полное удаление загрузки
// пользователем. Транспорт спрашивает его, чтобы не завести своего перечня
// состояний; проверку в ядре это не заменяет — допуск обязан держаться без
// транспорта (см. openspec/specs/state-reconciliation, «Полное удаление
// загрузки пользователем»).
func (s State) CanDelete() bool {
return slices.Contains(deletableStates, s)
}
// allowedTransitions — декларативный граф легальных переходов машины состояний // allowedTransitions — декларативный граф легальных переходов машины состояний
// (from → множество допустимых to). Единственный источник истины о легальности // (from → множество допустимых to). Единственный источник истины о легальности
// рёбер: покрывает все переходы, которые worker выполняет по всем capability // рёбер: покрывает все переходы, которые worker выполняет по всем capability
+49
View File
@@ -653,3 +653,52 @@ func TestListAndByState(t *testing.T) {
t.Fatalf("ListDownloadsByState(downloading) = %v", dl) t.Fatalf("ListDownloadsByState(downloading) = %v", dl)
} }
} }
// TestIsObservable: наблюдаемость — «состояние ещё может измениться без
// человека». Нетерминальные наблюдаемы все; из терминальных — те, которые фон
// возвращает в поток сам (failed по восстановимым кодам, target_missing и
// orphaned переоценивает сверка). done в перечень не входит сознательно: его
// переоценка означает удаление файлов мимо сервиса.
func TestIsObservable(t *testing.T) {
observable := []State{
StateCatched, StateDownloading, StateCompleted, StateRecognizing,
StateReview, StateLinking, StateDeferred, StateStuck,
StateFailed, StateTargetMissing, StateOrphaned,
}
silent := []State{StateDone, StateCancelled, StateReverted, StateDeleted}
for _, s := range observable {
if !s.IsObservable() {
t.Errorf("%s: IsObservable=false, want true", s)
}
}
for _, s := range silent {
if s.IsObservable() {
t.Errorf("%s: IsObservable=true, want false", s)
}
}
// Наблюдаемое множество не сводится к нетерминальному — иначе предикат был
// бы лишним, а карточка упавшей задачи замирала бы навсегда.
if !StateFailed.IsTerminal() || !StateFailed.IsObservable() {
t.Error("failed должно быть терминальным и при этом наблюдаемым")
}
}
// TestCanDelete: допуск полного удаления — единый дом перечня состояний, на
// который смотрят и ядро, и все транспорты. Перебираем ВСЕ состояния: новое
// состояние, попавшее в перечень молча, тест покажет.
func TestCanDelete(t *testing.T) {
deletable := []State{StateDone, StateOrphaned, StateTargetMissing}
for _, s := range allStates {
want := slices.Contains(deletable, s)
if got := s.CanDelete(); got != want {
t.Errorf("%s: CanDelete=%v, want %v", s, got, want)
}
}
// deleted терминально и удалению не подлежит — иначе повторная отправка
// формы группового удаления сносила бы уже снесённое второй раз.
if StateDeleted.CanDelete() {
t.Error("deleted не должно допускать удаление")
}
}
+36 -4
View File
@@ -89,6 +89,41 @@ func listWhere(f ListFilter) (string, []any) {
return " WHERE " + strings.Join(conds, " AND "), args return " WHERE " + strings.Join(conds, " AND "), args
} }
// listSelect и listOrder — общая форма выборки списка загрузок: LEFT JOIN
// текущей попытки распознавания (фолбэк заголовка и значок типа) и порядок по
// времени добавления с tie-break по id. Держим одним куском, чтобы страница
// списка и страница группового удаления не разъехались порядком.
const listSelect = `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type
FROM download
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1`
const listOrder = `
ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC`
// ListDeletableDownloads возвращает все загрузки, для которых домен допускает
// полное удаление, без постраничной выдачи: страница группового удаления
// показывает их разом, а разбиение по страницам лишило бы возможности выбрать
// пачку. Перечень состояний берётся из единой точки домена — своего списка
// запрос не держит.
func (s *Store) ListDeletableDownloads(ctx context.Context) ([]Download, error) {
ph := make([]string, len(deletableStates))
args := make([]any, len(deletableStates))
for i, st := range deletableStates {
ph[i] = "?"
args[i] = string(st)
}
q := listSelect + " WHERE download.state IN (" + strings.Join(ph, ",") + ")" + listOrder
var out []Download
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list deletable downloads: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, fmt.Errorf("list deletable downloads: %w", err)
}
return out, nil
}
// ListDownloadsPage возвращает страницу загрузок под фильтром и общее число // ListDownloadsPage возвращает страницу загрузок под фильтром и общее число
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени // строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break // добавления в источник (source_added_at) с фолбеком на created_at и tie-break
@@ -104,10 +139,7 @@ func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download
return nil, 0, fmt.Errorf("list downloads page count: %w", err) return nil, 0, fmt.Errorf("list downloads page count: %w", err)
} }
q := `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type q := listSelect + where + listOrder + `
FROM download
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where +
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
LIMIT ? OFFSET ?` LIMIT ? OFFSET ?`
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset) pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)
+36
View File
@@ -333,3 +333,39 @@ func TestCreateDownloadPersistsDisplayName(t *testing.T) {
t.Fatalf("display_name = %q", d.DisplayName) t.Fatalf("display_name = %q", d.DisplayName)
} }
} }
// Выборка для страницы группового удаления отдаёт ровно те состояния, где домен
// допускает полное удаление, — и ничего сверх них. Перебираем все состояния:
// разъезд запроса с предикатом иначе виден только на проде.
func TestListDeletableDownloads(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
all := []State{
StateCatched, StateDownloading, StateCompleted, StateRecognizing,
StateReview, StateLinking, StateDone, StateDeferred, StateStuck,
StateFailed, StateCancelled, StateReverted,
StateTargetMissing, StateOrphaned, StateDeleted,
}
byID := map[string]State{}
for i, s := range all {
byID[mkDownload(t, st, i+1, s, "задача "+string(s))] = s
}
got, err := st.ListDeletableDownloads(ctx)
if err != nil {
t.Fatal(err)
}
seen := map[State]bool{}
for _, d := range got {
if !d.State.CanDelete() {
t.Errorf("в выборке состояние %s, удаление из которого недоступно", d.State)
}
seen[d.State] = true
}
for _, s := range all {
if s.CanDelete() && !seen[s] {
t.Errorf("состояние %s допускает удаление, но в выборку не попало", s)
}
}
}
+15 -4
View File
@@ -262,10 +262,12 @@ func (b *Bot) downloadFile(ctx context.Context, fileID string) ([]byte, error) {
func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Request) { func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Request) {
res, err := b.ingestor.Ingest(ctx, req) res, err := b.ingestor.Ingest(ctx, req)
if err != nil { if err != nil {
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit); // Приём на любом пути ошибки возвращает нулевой Result (контракт
// при раннем разборе источника id ещё нет — даём дружелюбный текст без // ingest.Ingest) — идентификатора загрузки нет. Корреляционного ключа
// него (детали всё равно в логах на доменной границе). // у отказа приёма в Telegram нет вовсе (request_id — понятие
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil) // HTTP-границы): текст остаётся дружелюбным без ключа, детали — в
// записи приёма на доменной границе (capability=ingest, infohash).
b.send(chatID, opErr("Не удалось принять загрузку", ""), nil)
return return
} }
if res.Deduplicated { if res.Deduplicated {
@@ -398,6 +400,15 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil) b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
return return
} }
if errors.Is(err, layout.ErrNameTooLong) {
// Имя не помещается в файловую систему: задача штатно ушла в review,
// где название правится подсказкой. Карточку обновляем — в ней теперь
// причина.
b.answer(cq.ID, "Имя не помещается")
b.send(chatID, opErr("Целевое имя слишком длинное — задача отправлена в ревью", id), nil)
b.refreshCard(ctx, chatID, msgID, id)
return
}
if errors.Is(err, layout.ErrCollision) { if errors.Is(err, layout.ErrCollision) {
// Коллизия цели: задача штатно ушла в review с причиной — показываем // Коллизия цели: задача штатно ушла в review с причиной — показываем
// конкретно и обновляем карточку (в ней теперь причина коллизии). // конкретно и обновляем карточку (в ней теперь причина коллизии).
+52 -1
View File
@@ -3,6 +3,8 @@ package tgbot
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt"
"log/slog" "log/slog"
"strings" "strings"
"testing" "testing"
@@ -55,10 +57,15 @@ func (f *fakeAPI) GetFileDirectURL(string) (string, error)
type fakeIngestor struct { type fakeIngestor struct {
lastReq ingest.Request lastReq ingest.Request
res ingest.Result res ingest.Result
err error
} }
func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) { func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) {
f.lastReq = req f.lastReq = req
if f.err != nil {
// Контракт приёма: на ошибке результат нулевой (ingest.Ingest).
return ingest.Result{}, f.err
}
return f.res, nil return f.res, nil
} }
@@ -72,6 +79,7 @@ type fakeReviewer struct {
deleted []string deleted []string
dismissed []string dismissed []string
chosen map[string]string // downloadID → выбранный candidateID chosen map[string]string // downloadID → выбранный candidateID
applyErr error // чем отвечает Apply (nil — успехом)
} }
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) { func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
@@ -79,7 +87,7 @@ func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData,
} }
func (f *fakeReviewer) Apply(_ context.Context, id string) error { func (f *fakeReviewer) Apply(_ context.Context, id string) error {
f.applied = append(f.applied, id) f.applied = append(f.applied, id)
return nil return f.applyErr
} }
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error { func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
if f.refined == nil { if f.refined == nil {
@@ -585,3 +593,46 @@ func TestBot_CallbackStaleButton(t *testing.T) {
t.Errorf("answers = %v, want понятный ответ", api.answers) t.Errorf("answers = %v, want понятный ответ", api.answers)
} }
} }
// Отказ приёма в Telegram: идентификатора загрузки нет (контракт ingest.Ingest —
// нулевой результат на любом пути ошибки), и обещать его пользователю нельзя.
// Корреляционного ключа у этого транспорта тоже нет — записанный вопрос
// изменения `ingest-nits`; тест фиксирует сегодняшнее состояние, чтобы
// «download_id=» не вернулся в текст молча.
func TestBot_IngestErrorPromisesNoDownloadID(t *testing.T) {
b, api, ing, _ := newTestBot(t, []int64{7})
ing.err = errors.New("boom")
b.handleMessage(context.Background(), msgFrom(7, "magnet:?xt=urn:btih:ABC"))
if len(api.sent) != 1 {
t.Fatalf("отправлено сообщений = %d, want 1", len(api.sent))
}
got := api.sent[0].text
if !strings.Contains(got, "Не удалось принять загрузку") {
t.Errorf("текст отказа = %q", got)
}
if strings.Contains(got, "download_id") || strings.Contains(got, idCode(tid)) {
t.Errorf("в отказе обещан идентификатор загрузки: %q", got)
}
}
// Непомещающееся целевое имя — штатный отказ, чинимый на ревью: бот отвечает
// конкретно и обновляет карточку (в ней теперь причина), как для коллизии, а не
// падает в общее «Не удалось выполнить действие».
func TestBot_CallbackApplyNameTooLong(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.applyErr = fmt.Errorf("apply: %w", layout.ErrNameTooLong)
b.handleCallback(context.Background(), cbFrom(7, "apply:"+tid))
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "не помещается") {
t.Errorf("answers = %+v, ожидался конкретный ответ", api.answers)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "слишком длинное") {
t.Errorf("sent = %+v, ожидалось конкретное сообщение", api.sent)
}
if len(api.edits) != 1 {
t.Errorf("edits = %v, карточка должна обновиться причиной", api.edits)
}
}
+32 -7
View File
@@ -48,22 +48,20 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
case store.StateLinking: case store.StateLinking:
return "⏳ Раскладываю #" + idCode(id) + "…", nil return "⏳ Раскладываю #" + idCode(id) + "…", nil
case store.StateDone: case store.StateDone:
return b.renderDone(rd), b.deletableKeyboard(id) return b.renderDone(rd), b.stateKeyboard(id, state)
default: default:
// state — внутренний enum состояния (не внешний ввод), экранировать не нужно. // state — внутренний enum состояния (не внешний ввод), экранировать не нужно.
text := fmt.Sprintf("Задача #%s — %s.", idCode(id), state) text := fmt.Sprintf("Задача #%s — %s.", idCode(id), state)
if msg := rd.Download.ErrorMsg.String; msg != "" { if msg := rd.Download.ErrorMsg.String; msg != "" {
text += "\n" + esc(msg) text += "\n" + esc(msg)
} }
switch state { if state == store.StateFailed || state == store.StateStuck {
case store.StateFailed, store.StateStuck:
// failed/stuck — даём кнопку повтора. // failed/stuck — даём кнопку повтора.
return text, b.retryKeyboard(id) return text, b.retryKeyboard(id)
case store.StateOrphaned, store.StateTargetMissing:
// Рассинхрон — можно подчистить остатки полным удалением.
return text, b.deletableKeyboard(id)
} }
return text, b.webOnly(id) // Рассинхрон (orphaned/target_missing) — можно подчистить остатки полным
// удалением; какие именно состояния это допускают, знает домен.
return text, b.stateKeyboard(id, state)
} }
} }
@@ -89,11 +87,26 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
} }
if n := len(rd.Preview); n > 0 { if n := len(rd.Preview); n > 0 {
fmt.Fprintf(&sb, "План: %d файлов → %s", n, esc(tailPath(rd.Preview[0].Dst))) fmt.Fprintf(&sb, "План: %d файлов → %s", n, esc(tailPath(rd.Preview[0].Dst)))
} else if why := previewProblem(rd); why != "" {
// Плана нет — без этой строки карточка молчит о том, почему нет и «Применить».
// Причина показа (PreviewError) старше записанной: она про текущий план, а
// error_msg остался от последнего перехода.
fmt.Fprintf(&sb, "Не удаётся построить план: %s", esc(shorten(why, 120)))
} }
return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd) return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd)
} }
// previewProblem — почему у задачи в review нет плана: причина, посчитанная на
// показе, либо записанная при последнем переходе. Пусто — объяснять нечего
// (источник ещё не подтверждён).
func previewProblem(rd *worker.ReviewData) string {
if rd.PreviewError != "" {
return rd.PreviewError
}
return rd.Download.ErrorMsg.String
}
func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup { func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
id := rd.Download.ID id := rd.Download.ID
sid := id sid := id
@@ -277,6 +290,18 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
return &kb return &kb
} }
// stateKeyboard выбирает клавиатуру по допуску удаления: своего перечня
// состояний транспорт не держит, предикат живёт в домене (см.
// openspec/specs/state-reconciliation, «Полное удаление загрузки
// пользователем»). Допуск в ядре это не заменяет — worker.Delete проверяет его
// сам.
func (b *Bot) stateKeyboard(id string, state store.State) *tgbotapi.InlineKeyboardMarkup {
if state.CanDelete() {
return b.deletableKeyboard(id)
}
return b.webOnly(id)
}
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление // deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь // (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка // статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
+43
View File
@@ -0,0 +1,43 @@
package tgbot
import (
"strings"
"testing"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// Кнопка «Удалить» приходит ровно в тех состояниях, где домен допускает полное
// удаление. Транспорт своего перечня состояний не держит: разъехавшись с
// доменом, он показал бы кнопку, ведущую в конфликт, — или спрятал бы
// доступное действие.
func TestRenderCard_DeleteButtonFollowsCanDelete(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
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,
}
for _, st := range all {
rd := reviewData(st)
_, kb := b.renderCard(rd)
found := false
if kb != nil {
for _, row := range kb.InlineKeyboard {
for _, btn := range row {
if btn.CallbackData != nil && strings.HasPrefix(*btn.CallbackData, "delete:") {
found = true
}
}
}
}
if want := st.CanDelete(); found != want {
t.Errorf("%s: кнопка удаления=%v, CanDelete=%v", st, found, want)
}
}
}
@@ -0,0 +1,52 @@
package tgbot
import (
"strings"
"testing"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// Карточка ревью без плана называет причину: иначе владелец видит «Нужно
// подтверждение» без плана и без кнопки «Применить» и не знает, что чинить.
// Причина показа старше записанной — она про текущий план.
func TestReviewCard_NamesPreviewProblem(t *testing.T) {
t.Run("причина показа", func(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
rd := reviewData(store.StateReview)
rd.Preview = nil
rd.PreviewError = `layout: имя не помещается: "ЫЫЫ…ЫЫЫ" — 400 байт при пределе 255`
rd.Download.ErrorMsg = store.NullString("устаревшая причина прошлого перехода")
text, _ := b.renderCard(rd)
if !strings.Contains(text, "не помещается") {
t.Errorf("карточка не назвала причину:\n%s", text)
}
if strings.Contains(text, "устаревшая") {
t.Errorf("записанная причина не должна перебивать посчитанную на показе:\n%s", text)
}
})
t.Run("записанная причина, когда посчитанной нет", func(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
rd := reviewData(store.StateReview)
rd.Preview = nil
rd.Download.ErrorMsg = store.NullString("целевой файл уже существует")
text, _ := b.renderCard(rd)
if !strings.Contains(text, "уже существует") {
t.Errorf("карточка не назвала записанную причину:\n%s", text)
}
})
t.Run("причины нет — строки нет", func(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
rd := reviewData(store.StateReview)
rd.Preview = nil
text, _ := b.renderCard(rd)
if strings.Contains(text, "Не удаётся построить план") {
t.Errorf("без причины строки быть не должно:\n%s", text)
}
})
}
+34 -4
View File
@@ -33,7 +33,7 @@ type File struct {
type Info struct { type Info struct {
Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2) Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
Infohashes []string // все хеши файла (гибрид несёт v1 и v2); v1 раньше v2 Infohashes []string // все хеши файла (гибрид несёт v1 и v2); v1 раньше v2
DisplayName string // имя раздачи (info.name) DisplayName string // имя раздачи (info.name), нормализованное displayName; пусто, если имени нет
Files []File // файлы раздачи (для одиночного — один элемент) Files []File // файлы раздачи (для одиночного — один элемент)
TotalLength int64 // суммарный размер TotalLength int64 // суммарный размер
Trackers []string // announce + announce-list (плоско, без дублей) Trackers []string // announce + announce-list (плоско, без дублей)
@@ -69,7 +69,7 @@ func Parse(data []byte) (Info, error) {
return Info{ return Info{
Infohash: hashes[0], Infohash: hashes[0],
Infohashes: hashes, Infohashes: hashes,
DisplayName: meta.BestName(), DisplayName: displayName(meta.BestName()),
Files: fs, Files: fs,
TotalLength: total, TotalLength: total,
Trackers: mi.UpvertedAnnounceList().DistinctValues(), Trackers: mi.UpvertedAnnounceList().DistinctValues(),
@@ -77,6 +77,33 @@ func Parse(data []byte) (Info, error) {
}, nil }, nil
} }
// displayName нормализует имя раздачи на границе разбора, чтобы ниже по потоку
// (строка названия контекста, source_ref приёма, подсказка вывода отображаемого
// имени) вырожденных значений не встречалось.
//
// Пробельное схлопываем тем же oneLine, что и комментарий: имя — недоверенный
// вход, а контекст распознавания читается построчно, и имя с переводом строки
// добавило бы в него строку, выглядящую как синтезированный нами факт. Защитой
// от инъекции в промпт это НЕ является — пользовательский текст контекста
// многострочен по замыслу; правило лишь снимает несогласованность между именем
// и комментарием.
//
// Затем отбрасываем `metainfo.NoName` — раздача объявляет это значение сама,
// полем name, по конвенции «имени нет» (библиотека экспортирует константу
// именно затем, чтобы на неё ссылались). Раздача без поля name даёт пустое имя
// и так.
//
// Порядок здесь существенный, и обратный — дефект: имя `" - "` сравнение с
// сентинелом не проходит, а схлопывание превращает его ровно в сентинел, и тот
// уезжает вниз по потоку всеми тремя путями.
func displayName(name string) string {
name = oneLine(name)
if name == metainfo.NoName {
return ""
}
return name
}
// files собирает файлы раздачи и суммарный размер из info (одиночный файл или // files собирает файлы раздачи и суммарный размер из info (одиночный файл или
// дерево). Байты .torrent — недоверенный вход: на кривых/вырожденных раздачах // дерево). Байты .torrent — недоверенный вход: на кривых/вырожденных раздачах
// библиотека (UpvertedFiles/FileTree) способна паниковать (напр. делением на // библиотека (UpvertedFiles/FileTree) способна паниковать (напр. делением на
@@ -111,8 +138,11 @@ var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.
func (i Info) Context() string { func (i Info) Context() string {
var lines []string var lines []string
if name := strings.TrimSpace(i.DisplayName); name != "" { // Имя уже нормализовано разборщиком (displayName): вырожденное значение и
lines = append(lines, name) // разделители строк сюда не доходят. Проверка на пустоту остаётся — имени
// у раздачи может не быть вовсе, и тогда строки названия просто нет.
if i.DisplayName != "" {
lines = append(lines, i.DisplayName)
} }
if i.TotalLength > 0 { if i.TotalLength > 0 {
lines = append(lines, "Размер: "+humanSize(i.TotalLength)) lines = append(lines, "Размер: "+humanSize(i.TotalLength))
+88 -6
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha1" "crypto/sha1"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt"
"strings" "strings"
"testing" "testing"
@@ -217,12 +218,25 @@ func TestParseInvalidBytes(t *testing.T) {
} }
} }
func TestContextEmptyWhenNoFields(t *testing.T) { // Раздача объявила вырожденное имя `-` (metainfo.NoName — конвенция «имени
// info без имени (NoName-сентинел), без трекера/комментария: строк-фактов // нет», которую раздача выставляет САМА; библиотека его не синтезирует).
// минимум. Проверяем, что Context не паникует и не тянет сеть (чистая // Разборщик нормализует его к пустой строке, поэтому строки названия в
// функция — сетевых вызовов в коде нет по построению). // контексте нет. Трекера и комментария тоже нет — строк-фактов минимум.
func TestParseNoNameSentinelDropped(t *testing.T) {
// Сентинел вокруг пробельного — тот же вырожденный вход: схлопывание
// обязано идти ДО сравнения, иначе " - " превращается в "-" уже после
// проверки и уезжает вниз по потоку (регрессия против master, где фолбек
// source_ref на имя файла срабатывал).
for _, name := range []string{
metainfo.NoName,
" - ",
"-\n",
"\t-",
"\u00a0-",
} {
t.Run(fmt.Sprintf("%q", name), func(t *testing.T) {
data, _ := build(t, metainfo.Info{ data, _ := build(t, metainfo.Info{
Name: "-", // metainfo.NoName Name: name,
Length: 0, Length: 0,
PieceLength: 1024, PieceLength: 1024,
Pieces: pieces(1), Pieces: pieces(1),
@@ -231,5 +245,73 @@ func TestContextEmptyWhenNoFields(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("parse: %v", err) t.Fatalf("parse: %v", err)
} }
_ = info.Context() // не паникует; состав строк — по полям if info.DisplayName != "" {
t.Errorf("DisplayName = %q, want пусто (вырожденное имя)", info.DisplayName)
}
if got := info.Context(); got != "" {
t.Errorf("Context() = %q, want пусто (строки названия быть не должно)", got)
}
})
}
}
// Раздача без поля name даёт пустое имя и без нормализации — случай отдельный
// от вырожденного `-` и путать их нельзя (именно эта путаница в комментарии
// теста и породила исходную нить ревью 2026-07-08).
func TestParseMissingNameIsEmpty(t *testing.T) {
data, _ := build(t, metainfo.Info{
Length: 0,
PieceLength: 1024,
Pieces: pieces(1),
}, "", "")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.DisplayName != "" {
t.Errorf("DisplayName = %q, want пусто (поля name нет)", info.DisplayName)
}
if got := info.Context(); got != "" {
t.Errorf("Context() = %q, want пусто", got)
}
}
// Имя — недоверенный вход, а контекст читается построчно: имя с разделителем
// строк не должно добавлять в контекст строку, выглядящую как синтезированный
// нами факт. Число строк такое же, как у раздачи с обычным именем.
func TestParseNameCollapsedToOneLine(t *testing.T) {
// \n, U+2028 (LINE SEPARATOR) и U+2029 (PARAGRAPH SEPARATOR) — всё, чем можно
// разорвать строку; краевые пробелы туда же.
const dirty = " Dune.2024\nТрекер: evil.example\u2028Комментарий: подделка\u2029хвост "
const want = "Dune.2024 Трекер: evil.example Комментарий: подделка хвост"
data, _ := build(t, metainfo.Info{
Name: dirty,
Length: 2100,
PieceLength: 1024,
Pieces: pieces(3),
}, "http://bt.rutracker.org/announce", "")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.DisplayName != want {
t.Errorf("DisplayName = %q, want %q", info.DisplayName, want)
}
clean, _ := build(t, metainfo.Info{
Name: "Dune.2024",
Length: 2100,
PieceLength: 1024,
Pieces: pieces(3),
}, "http://bt.rutracker.org/announce", "")
ref, err := Parse(clean)
if err != nil {
t.Fatalf("parse clean: %v", err)
}
gotLines := len(strings.Split(info.Context(), "\n"))
wantLines := len(strings.Split(ref.Context(), "\n"))
if gotLines != wantLines {
t.Errorf("строк в контексте = %d, want %d (грязное имя добавило строк):\n%s",
gotLines, wantLines, info.Context())
}
} }
+3 -1
View File
@@ -18,13 +18,15 @@ type fakeNamer struct {
name string name string
fields naming.Fields // извлечённая структура (ok=true, если Title непуст) fields naming.Fields // извлечённая структура (ok=true, если Title непуст)
gotContext string gotContext string
gotHint string // подсказка имени — третий потребитель torrent.Info.DisplayName
calls int calls int
onCall func() onCall func()
} }
func (f *fakeNamer) Derive(_ context.Context, contextText, _ string) (string, naming.Fields, bool) { func (f *fakeNamer) Derive(_ context.Context, contextText, hint string) (string, naming.Fields, bool) {
f.calls++ f.calls++
f.gotContext = contextText f.gotContext = contextText
f.gotHint = hint
if f.onCall != nil { if f.onCall != nil {
f.onCall() f.onCall()
} }
+77 -13
View File
@@ -325,7 +325,15 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag(provider, providerID), folderBase)) links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag(provider, providerID), folderBase))
if err != nil { if err != nil {
w.transition(ctx, *d, store.StateReview, reasonBuild, err.Error()) // Непомещающееся имя — свой код причины: этот отказ человек чинит
// подсказкой названия, а прочие отказы построения (пустое название,
// серия без номера) — нет, и слепить их в один код значило бы потерять
// эту разницу.
code := reasonBuild
if errors.Is(err, layout.ErrNameTooLong) {
code = reasonNameTooLong
}
w.transition(ctx, *d, store.StateReview, code, err.Error())
return fmt.Errorf("build links: %w", err) return fmt.Errorf("build links: %w", err)
} }
@@ -404,6 +412,10 @@ func (w *Worker) Relink(ctx context.Context, id string) (err error) {
if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing { if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing {
return fmt.Errorf("relink: download %s is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict) return fmt.Errorf("relink: download %s is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
} }
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity): иначе запись клиента об отказе уходит
// без download_id/infohash.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
// Источник нужен для распознавания и должен быть докачан — проверяем // Источник нужен для распознавания и должен быть докачан — проверяем
// синхронно (без дебаунса); отсутствие приводит состояние к реальности // синхронно (без дебаунса); отсутствие приводит состояние к реальности
// (orphaned/deleted), недокачанный — ErrNotReady. // (orphaned/deleted), недокачанный — ErrNotReady.
@@ -414,7 +426,6 @@ func (w *Worker) Relink(ctx context.Context, id string) (err error) {
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil { if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
return fmt.Errorf("relink: %w", err) return fmt.Errorf("relink: %w", err)
} }
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
// Возврат в активную обработку — только через атомарный гард инварианта // Возврат в активную обработку — только через атомарный гард инварианта
// «не более одной активной задачи на infohash» (см. design ulid-identity, D4). // «не более одной активной задачи на infohash» (см. design ulid-identity, D4).
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateRecognizing, "", ""); err != nil { if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateRecognizing, "", ""); err != nil {
@@ -439,10 +450,13 @@ func (w *Worker) Rerecognize(ctx context.Context, id string) (err error) {
if err != nil { if err != nil {
return err return err
} }
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity): иначе запись клиента об отказе уходит
// без download_id/infohash.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
if err := w.ensureSourceReady(ctx, d, "rerecognize"); err != nil { if err := w.ensureSourceReady(ctx, d, "rerecognize"); err != nil {
return err return err
} }
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
logctx.From(ctx).Info("review re-recognizing without hint") logctx.From(ctx).Info("review re-recognizing without hint")
w.transition(ctx, *d, store.StateRecognizing, "", "") w.transition(ctx, *d, store.StateRecognizing, "", "")
return nil return nil
@@ -462,10 +476,13 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error)
if err != nil { if err != nil {
return err return err
} }
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity): иначе запись клиента об отказе уходит
// без download_id/infohash.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
if err := w.ensureSourceReady(ctx, d, "refine"); err != nil { if err := w.ensureSourceReady(ctx, d, "refine"); err != nil {
return err return err
} }
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
if err := w.store.AddHint(ctx, id, hint); err != nil { if err := w.store.AddHint(ctx, id, hint); err != nil {
return fmt.Errorf("refine: %w", err) return fmt.Errorf("refine: %w", err)
} }
@@ -611,9 +628,7 @@ func (w *Worker) Delete(ctx context.Context, id string) (err error) {
if err != nil { if err != nil {
return fmt.Errorf("delete: %w", err) return fmt.Errorf("delete: %w", err)
} }
switch d.State { if !d.State.CanDelete() {
case store.StateDone, store.StateOrphaned, store.StateTargetMissing:
default:
return fmt.Errorf("delete: download %s is in state %s (expected done/orphaned/target_missing): %w", id, d.State, ErrConflict) return fmt.Errorf("delete: download %s is in state %s (expected done/orphaned/target_missing): %w", id, d.State, ErrConflict)
} }
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash()) ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
@@ -688,6 +703,10 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) (e
if err != nil { if err != nil {
return err return err
} }
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity): иначе запись клиента об отказе уходит
// без download_id/infohash.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
rec, err := w.store.GetCurrentRecognition(ctx, id) rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil { if err != nil {
return fmt.Errorf("choose candidate: %w", err) return fmt.Errorf("choose candidate: %w", err)
@@ -724,6 +743,10 @@ func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID s
if err != nil { if err != nil {
return err return err
} }
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity): иначе запись клиента об отказе уходит
// без download_id/infohash.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
rec, err := w.store.GetCurrentRecognition(ctx, id) rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil { if err != nil {
return fmt.Errorf("add source: %w", err) return fmt.Errorf("add source: %w", err)
@@ -795,7 +818,8 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
if w.recognizer != nil { if w.recognizer != nil {
director = w.recognizer.Director(ctx, candMediaType(rec), cand.Provider, cand.ProviderID) director = w.recognizer.Director(ctx, candMediaType(rec), cand.Provider, cand.ProviderID)
} }
for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year, director) { pins := sourcePins(cand.Provider, cand.ProviderID, title, year, director)
for field, value := range pins {
if err := w.store.SetOverride(ctx, id, field, value); err != nil { if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("choose candidate: %w", err) return fmt.Errorf("choose candidate: %w", err)
} }
@@ -803,13 +827,18 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
if err := w.store.SetCandidateChosen(ctx, rec.ID, cand.ID); err != nil { if err := w.store.SetCandidateChosen(ctx, rec.ID, cand.ID); err != nil {
return fmt.Errorf("choose candidate: %w", err) return fmt.Errorf("choose candidate: %w", err)
} }
// title_pinned=false означает, что название кандидата не годится как имя
// каталога и в плане осталось название распознавания. Без этого атрибута
// вопрос «почему папка названа догадкой, а не как в базе» по логам не
// разбирается: на авто-пути такой отказ несёт причина решения, здесь её нет.
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen", logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
"provider", cand.Provider, "provider_id", cand.ProviderID) "provider", cand.Provider, "provider_id", cand.ProviderID,
"title_pinned", pins[ovrTitle] != "")
// Подтверждённый матч — переливаем каноническое имя в display_name и в ярлык // Подтверждённый матч — переливаем каноническое имя в display_name и в ярлык
// раздачи (best-effort, косметика). Сбой обновления имени не должен ронять // раздачи (best-effort, косметика). Сбой обновления имени не должен ронять
// выбор кандидата: логируем и продолжаем. // выбор кандидата: логируем и продолжаем.
if err := w.refreshDisplayNameLocked(ctx, id); err != nil { if err := w.refreshDisplayNameLocked(ctx, id); err != nil {
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())). logctx.From(ctx).
Warn("display name refresh after candidate choice failed", "error", err) Warn("display name refresh after candidate choice failed", "error", err)
} }
return nil return nil
@@ -835,6 +864,10 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
if err != nil { if err != nil {
return err return err
} }
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity): иначе запись клиента об отказе уходит
// без download_id/infohash.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
// Режиссёр вручную заданного источника из метабазы (credits) — best-effort // Режиссёр вручную заданного источника из метабазы (credits) — best-effort
// косметика: сбой чтения рекогниции не валит смену источника (тип по умолчанию // косметика: сбой чтения рекогниции не валит смену источника (тип по умолчанию
// movie, как трактует candMediaType(nil)). // movie, как трактует candMediaType(nil)).
@@ -842,7 +875,7 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
if w.recognizer != nil { if w.recognizer != nil {
rec, rerr := w.store.GetCurrentRecognition(ctx, id) rec, rerr := w.store.GetCurrentRecognition(ctx, id)
if rerr != nil { if rerr != nil {
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())). logctx.From(ctx).
Warn("set provider: recognition lookup for director failed", "error", rerr) Warn("set provider: recognition lookup for director failed", "error", rerr)
rec = nil rec = nil
} }
@@ -890,6 +923,18 @@ func sourcePins(provider, providerID, title string, year int, director string) m
if year > 0 { if year > 0 {
yr = strconv.Itoa(year) yr = strconv.Itoa(year)
} }
// Название источника — недоверенное значение метабазы, и чистится оно здесь,
// на единственном общем доме набора пинов: через sourcePins идут и превью
// источника, и его закрепление, поэтому «превью = применение» держится
// конструкцией, а не памятью. Чистка идемпотентна — на кандидате, записанном
// уже с чисткой, она ничего не меняет, а строку из БД, сохранённую прежней
// версией, приводит в порядок. Непригодное как имя каталога название пином не
// становится: пустое значение очищает пин, и в плане остаётся название
// распознавания.
title = recognize.SanitizeTitle(title)
if !recognize.UsableTitle(title) {
title = ""
}
return map[string]string{ return map[string]string{
ovrProvider: provider, ovrProvider: provider,
ovrProviderID: providerID, ovrProviderID: providerID,
@@ -916,6 +961,13 @@ type ReviewData struct {
Recognition *store.Recognition Recognition *store.Recognition
Plan recognize.Plan // эффективный (с применёнными правками) Plan recognize.Plan // эффективный (с применёнными правками)
Preview []layout.Link // целевые пути активного источника (Src — относительный) Preview []layout.Link // целевые пути активного источника (Src — относительный)
// PreviewError — почему предпросмотр не построился, человекочитаемо (пусто —
// построился либо строить было нечего). Считается НА ПОКАЗЕ, поэтому всегда
// относится к текущему эффективному плану — в отличие от error_msg загрузки,
// который остался от последнего перехода и после смены источника устаревает.
// Задача в review без записанной причины (обычный «нет матча») иначе оставила
// бы экран без объяснения, почему пропала кнопка «Применить».
PreviewError string
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты) Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
Provider string // эффективный провайдер (с учётом выбора) Provider string // эффективный провайдер (с учётом выбора)
@@ -1043,8 +1095,12 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
rd.Preview = links rd.Preview = links
} else { } else {
// Видимая деградация: без превью на экране ревью пропадает // Видимая деградация: без превью на экране ревью пропадает
// кнопка «Применить» — не рядовой Debug, а WARN. // кнопка «Применить» — не рядовой Debug, а WARN. Причину
// отдаём транспорту: молча пропавшая кнопка неотличима от
// поломки, а у задачи без записанной причины взять её больше
// неоткуда.
log.Warn("review data build preview failed", "error", lerr) log.Warn("review data build preview failed", "error", lerr)
rd.PreviewError = lerr.Error()
} }
} }
// Единый список источников: нейронка + кандидаты, каждый с // Единый список источников: нейронка + кандидаты, каждый с
@@ -1228,6 +1284,7 @@ const (
reasonBuild = "build" // не удалось построить план ссылок reasonBuild = "build" // не удалось построить план ссылок
reasonPersist = "persist" // ссылки на диске, но учёт не записан reasonPersist = "persist" // ссылки на диске, но учёт не записан
reasonCollision = "collision" // целевой путь уже занят (layout.ErrCollision) reasonCollision = "collision" // целевой путь уже занят (layout.ErrCollision)
reasonNameTooLong = "name_too_long" // компонент целевого пути длиннее предела (layout.ErrNameTooLong)
reasonTitleFolderDesync = "title_folder_desync" // ≥2 разных живых папок тайтла с одним матчем reasonTitleFolderDesync = "title_folder_desync" // ≥2 разных живых папок тайтла с одним матчем
) )
@@ -1276,8 +1333,15 @@ func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, pr
// applyOverrides применяет ручные правки к плану: каноническое имя/год (из // applyOverrides применяет ручные правки к плану: каноническое имя/год (из
// выбранного кандидата базы) и помечает игнорируемые файлы ролью ignore (их // выбранного кандидата базы) и помечает игнорируемые файлы ролью ignore (их
// раскладка пропустит). // раскладка пропустит).
//
// Название чистится здесь, НА ЧТЕНИИ, и это не дубль чистки в sourcePins: та
// держит хранилище чистым, а эта защищает от значений, записанных прежними
// версиями. Пин, закреплённый до появления санитайзинга, иначе доезжает до имени
// каталога дословно при обычном «Применить» — без повторного выбора источника
// запись в хранилище никто не перепишет. Чистка идемпотентна, так что на пинах,
// записанных уже с ней, обе не делают ничего.
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan { func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
if t := overrides[ovrTitle]; t != "" { if t := recognize.SanitizeTitle(overrides[ovrTitle]); recognize.UsableTitle(t) {
plan.Title = t plan.Title = t
} }
if y := overrides[ovrYear]; y != "" { if y := overrides[ovrYear]; y != "" {
+260
View File
@@ -2285,3 +2285,263 @@ func TestSweepLinking_LeavesOtherStates(t *testing.T) {
t.Errorf("review task moved to %q", st.downloads["2"].State) t.Errorf("review task moved to %q", st.downloads["2"].State)
} }
} }
// Кандидат с непригодным названием (голая пунктуация — TVDB правится
// сообществом, мусорные записи там штатны): пин названия не ставится, и в
// эффективном плане остаётся название распознавания. Иначе в библиотеке
// появился бы каталог «- (2000)», а на «.» раскладка упала бы ошибкой слоя
// layout — тот же исход, что и у канонического названия на авто-пути.
func TestChooseCandidate_UnusableTitleNotPinned(t *testing.T) {
for _, title := range []string{"-", ".", "..."} {
t.Run(title, func(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString(title),
Year: sql.NullInt64{Int64: 2014, Valid: true},
})
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if ov := st.overrides["1"]; ov[ovrTitle] != "" {
t.Errorf("непригодное название закреплено: %q", ov[ovrTitle])
}
// Провайдер, id и год закрепляются как обычно — матч верен.
if ov := st.overrides["1"]; ov[ovrProviderID] != "269613" || ov[ovrYear] != "2014" {
t.Errorf("overrides = %v", st.overrides["1"])
}
plan, _, _, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Догадка" {
t.Errorf("plan.Title = %q, want название распознавания", plan.Title)
}
})
}
}
// Кандидат, сохранённый ПРЕЖНЕЙ версией (до чистки на входе в список), лежит в
// БД грязным. Гарантия чистоты не может держаться на времени записи: чистка
// стоит на закреплении, в sourcePins, и идемпотентна — на новых строках это
// no-op. Без неё невидимка доезжает до имени каталога, потому что
// layout.sanitizeComponent категорию Cf не трогает.
func TestChooseCandidate_DirtyLegacyTitleSanitized(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString("Far\u200bgo"),
Year: sql.NullInt64{Int64: 2014, Valid: true},
})
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if got := st.overrides["1"][ovrTitle]; got != "Fargo" {
t.Errorf("закреплено %q, want %q — грязная строка из БД не очищена", got, "Fargo")
}
plan, _, _, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Fargo" {
t.Errorf("plan.Title = %q", plan.Title)
}
}
// Превью = применение: строка источника показывает ровно то название, которое
// закрепится по клику. Прежде превью считалось из сырого названия кандидата, а
// гейт пригодности стоял только на закреплении — экран обещал одно, раскладка
// делала другое.
func TestBuildSources_PreviewMatchesApply(t *testing.T) {
for _, c := range []struct{ name, dbTitle, want string }{
{"непригодное название", "-", "Догадка"},
{"грязное название", "Far\u200bgo", "Fargo"},
} {
t.Run(c.name, func(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString(c.dbTitle),
Year: sql.NullInt64{Int64: 2014, Valid: true},
})
rd, err := w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
var src *SourceOption
for i := range rd.Sources {
if rd.Sources[i].Kind == SourceCandidate {
src = &rd.Sources[i]
}
}
if src == nil {
t.Fatal("кандидат не попал в источники")
}
if src.Title != c.want {
t.Errorf("превью показывает %q, want %q", src.Title, c.want)
}
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
plan, _, _, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != src.Title {
t.Errorf("превью %q, применилось %q — расхождение", src.Title, plan.Title)
}
})
}
}
// Пин, закреплённый ПРЕЖНЕЙ версией, лежит в overrides грязным, и обычное
// «Применить» его не переписывает — источник заново не выбирают. Поэтому чистка
// стоит и на чтении: иначе раздача, стоящая в ревью на момент выката, создаёт
// ровно тот каталог, ради которого затевалась правка.
func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) {
plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Догадка", Year: 2000}
got := applyOverrides(plan, map[string]string{ovrTitle: "Fa\u200brgo"})
if got.Title != "Fargo" {
t.Errorf("plan.Title = %q, want %q — грязный пин доехал до раскладки", got.Title, "Fargo")
}
// Непригодный пин названием не становится: остаётся название распознавания.
if got := applyOverrides(plan, map[string]string{ovrTitle: "-"}); got.Title != "Догадка" {
t.Errorf("plan.Title = %q, want название распознавания", got.Title)
}
}
// Раскладка с непомещающимся именем уводит задачу в review со своим кодом
// причины, а не в failed с текстом ядра. Каталог библиотеки при этом пуст:
// проверка стоит до первой операции с ФС (критерии приёмки A1, A2, A4).
func TestApply_NameTooLongGoesReview(t *testing.T) {
plan := seriesResult().Plan
plan.Title = strings.Repeat("ы", 200) // 400 байт — не помещается ни в имя, ни в папку
f := newApplyFixture(t, plan)
err := f.w.Apply(context.Background(), "1")
if !errors.Is(err, layout.ErrNameTooLong) {
t.Fatalf("Apply err = %v, want ErrNameTooLong", err)
}
d := f.st.downloads["1"]
if d.State != store.StateReview {
t.Errorf("state = %q, want review (а не failed)", d.State)
}
if d.ErrorCode.String != reasonNameTooLong {
t.Errorf("error_code = %q, want %q", d.ErrorCode.String, reasonNameTooLong)
}
// A3: причина — наш доменный текст, а не текст ядра.
if msg := d.ErrorMsg.String; strings.Contains(msg, "file name too long") ||
strings.Contains(msg, "ENAMETOOLONG") {
t.Errorf("error_msg несёт текст системной ошибки: %q", msg)
}
if !strings.Contains(d.ErrorMsg.String, "не помещается") {
t.Errorf("error_msg не называет причину по-человечески: %q", d.ErrorMsg.String)
}
// A2/A4: под библиотекой не появилось ничего.
entries, rerr := os.ReadDir(f.series)
if rerr != nil {
t.Fatal(rerr)
}
if len(entries) != 0 {
t.Errorf("под series появилось %d записей, ожидалось 0", len(entries))
}
if len(f.st.links) != 0 {
t.Errorf("file_links = %d, want 0 (частичной раскладки не остаётся)", len(f.st.links))
}
}
// Прочие отказы построения плана свой код сохраняют: непомещающееся имя не
// поглотило соседний случай.
func TestApply_EmptyTitleKeepsBuildReason(t *testing.T) {
plan := seriesResult().Plan
plan.Title = "..." // санитизация схлопывает в пустое
f := newApplyFixture(t, plan)
if err := f.w.Apply(context.Background(), "1"); err == nil {
t.Fatal("want build error")
}
if got := f.st.downloads["1"].ErrorCode.String; got != reasonBuild {
t.Errorf("error_code = %q, want %q", got, reasonBuild)
}
}
// Экран ревью обязан назвать причину, даже когда в состоянии её нет. Самый
// частый вход в review — распознавание без подтверждённого матча — приходит
// туда с пустым error_msg и до раскладки не доходит, поэтому единственный
// источник причины здесь — построение предпросмотра.
func TestReviewData_PreviewErrorWhenStateHasNoReason(t *testing.T) {
plan := seriesResult().Plan
plan.Title = strings.Repeat("ы", 200)
f := newApplyFixture(t, plan)
// Состояние без записанной причины: так задачу оставляет finishRecognition.
d := f.st.downloads["1"]
d.ErrorCode = store.NullString("")
d.ErrorMsg = store.NullString("")
f.st.put(d)
rd, err := f.w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
if len(rd.Preview) != 0 {
t.Fatalf("предпросмотр = %d ссылок, ожидался пустой", len(rd.Preview))
}
if rd.PreviewError == "" {
t.Fatal("предпросмотр пуст, а причины нет — экран промолчит о том, почему пропала кнопка")
}
if !strings.Contains(rd.PreviewError, "не помещается") {
t.Errorf("PreviewError = %q, ожидалась причина по длине имени", rd.PreviewError)
}
}
// Сценарий спеки «Подсказка человека чинит случай, когда база печатается из
// распознавания»: короткое название доводит задачу до done.
func TestApply_ShorterTitleFixesNameTooLong(t *testing.T) {
plan := seriesResult().Plan
plan.Title = strings.Repeat("ы", 200)
f := newApplyFixture(t, plan)
if err := f.w.Apply(context.Background(), "1"); !errors.Is(err, layout.ErrNameTooLong) {
t.Fatalf("подготовка: ожидался отказ по длине, got %v", err)
}
// Человек задаёт название короче (жёсткая правка поля — override).
if err := f.st.SetOverride(context.Background(), "1", ovrTitle, "Шоу"); err != nil {
t.Fatal(err)
}
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply после укорачивания: %v", err)
}
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Errorf("state = %q, want done", got)
}
}
// Унаследованная база отказа по длине дать не может, и это свойство арифметики,
// а не совпадение: папка-якоря лежит на диске, значит её имя <= предела, а хвост
// имени файла (" S02E01" + расширение, 11 байт) не длиннее хвоста папки
// (" [" + provider-тег + "]", минимум 11 байт). Тест держит это свойство: если
// суффиксы имён вырастут (язык и флаги субтитров, двойные серии), он покраснеет
// раньше, чем задача упрётся в тупик, из которого подсказка не выводит.
func TestApply_InheritedBaseAtLimitStillFits(t *testing.T) {
plan := seriesResult().Plan
f := newApplyFixture(t, plan)
prov, pid := "tvdb", "269613"
tag := prov + "id-" + pid
// Папка якоря ровно в предел: длиннее её на диске быть не может.
base := strings.Repeat("a", 255-len(" [")-len(tag)-len("]"))
anchorDir := filepath.Join(f.series, base+" ["+tag+"]")
if err := os.MkdirAll(filepath.Join(anchorDir, "Season 01"), 0o755); err != nil {
t.Fatal(err)
}
f.st.links = append(f.st.links, store.FileLink{
DownloadID: "other", DstPath: filepath.Join(anchorDir, "Season 01", base+" S01E01.mkv"),
Status: string(layout.StatusLinked),
})
_ = f.st.SetOverride(context.Background(), "1", ovrProvider, prov)
_ = f.st.SetOverride(context.Background(), "1", ovrProviderID, pid)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("унаследованная база в предел должна раскладываться: %v", err)
}
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Errorf("state = %q, want done", got)
}
}
+162
View File
@@ -5,11 +5,14 @@ import (
"context" "context"
"crypto/sha1" "crypto/sha1"
"encoding/hex" "encoding/hex"
"errors"
"log/slog"
"testing" "testing"
"github.com/anacrolix/torrent/bencode" "github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo" "github.com/anacrolix/torrent/metainfo"
"git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
) )
@@ -118,3 +121,162 @@ func TestRetryTorrentMissingBytesRollsBack(t *testing.T) {
t.Errorf("state = %q, want failed (откат активации)", st.downloads["1"].State) t.Errorf("state = %q, want failed (откат активации)", st.downloads["1"].State)
} }
} }
// Третий потребитель torrent.Info.DisplayName — подсказка вывода отображаемого
// имени, то есть вход LLM. Раздача, объявившая вырожденное имя `-`, не должна
// отдавать его подсказкой: нормализация стоит на границе разбора, и здесь
// проверяется, что она туда доехала (собственный оракул, а не транзитивный).
func TestProcessCatchedNoNameGivesEmptyHint(t *testing.T) {
data, ih := buildTorrent(t, metainfo.NoName)
st := catchedTorrentStore("1", data, ih)
qb := &fakeQbt{}
w := newTestWorker(st, qb)
namer := &fakeNamer{name: "Дюна (2024)"}
w.SetNamer(namer)
w.processCatched(context.Background())
if namer.calls == 0 {
t.Fatal("namer не вызывался — тест ничего не проверил")
}
if namer.gotHint != "" {
t.Errorf("подсказка имени = %q, want пусто (вырожденное имя раздачи)", namer.gotHint)
}
}
// probeHandler захватывает атрибуты, доклеенные логгером. Свой WithAttrs
// обязателен: делегированный вернул бы чужой хендлер, и scoped-логгер (он
// собирается через With) перестал бы захватываться.
type probeHandler struct {
attrs *[]slog.Attr
}
func (h probeHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h probeHandler) Handle(context.Context, slog.Record) error { return nil }
func (h probeHandler) WithAttrs(as []slog.Attr) slog.Handler {
*h.attrs = append(*h.attrs, as...)
return h
}
func (h probeHandler) WithGroup(string) slog.Handler { return h }
// Обязанность вызывающего (capability identity): команда, делающая вызов
// внешнего сервиса в контексте загрузки, кладёт scoped-логгер в ctx ДО этого
// вызова. Без этого запись клиента qBittorrent об отказе добавления уходит без
// download_id/infohash, а причины отказа qBittorrent не сообщает («Fails.») —
// корреляция там единственное, что делает запись пригодной для разбора.
func TestRetryPassesScopedContextToAdd(t *testing.T) {
data, ih := buildTorrent(t, "Fargo.mkv")
st := catchedTorrentStore("1", data, ih)
st.downloads["1"].State = store.StateFailed
qb := &fakeQbt{}
w := newTestWorker(st, qb)
var attrs []slog.Attr
w.log = slog.New(probeHandler{attrs: &attrs})
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if len(qb.addCtx) != 1 {
t.Fatalf("вызовов Add = %d, want 1", len(qb.addCtx))
}
if logctx.FromOr(qb.addCtx[0], nil) == nil {
t.Fatal("в ctx вызова Add нет scoped-логгера загрузки")
}
got := map[string]string{}
for _, a := range attrs {
got[a.Key] = a.Value.String()
}
if got["download_id"] != "1" {
t.Errorf("download_id = %q, want 1", got["download_id"])
}
if got["infohash"] != ih {
t.Errorf("infohash = %q, want %q", got["infohash"], ih)
}
if got["capability"] == "" {
t.Errorf("нет capability в scoped-логгере: %v", got)
}
}
// Best-effort ветки Retry: откат активации при провале Add и сбросы базиса
// таймаутов/счётчика пропусков. Ветки переписаны этим изменением (ручные
// w.log.… схлопнуты в logctx.From(ctx)), поэтому проверяем не только исход, но
// и что аварийная запись несёт корреляцию из scoped-контекста — ради неё
// scoped-логгер сюда и заводился.
func TestRetryBestEffortBranchesCarryScope(t *testing.T) {
t.Run("откат активации при провале Add", func(t *testing.T) {
data, ih := buildTorrent(t, "Fargo.mkv")
st := catchedTorrentStore("1", data, ih)
st.downloads["1"].State = store.StateFailed
qb := &fakeQbt{addErr: errors.New("qbit down")}
w := newTestWorker(st, qb)
st.setStateErr = errors.New("db down") // и откат тоже не проходит
var attrs []slog.Attr
w.log = slog.New(probeHandler{attrs: &attrs})
if err := w.Retry(context.Background(), "1"); err == nil {
t.Fatal("ожидалась ошибка добавления")
}
assertScopeAttrs(t, attrs, ih)
})
t.Run("сброс базиса таймаутов не прошёл", func(t *testing.T) {
data, ih := buildTorrent(t, "Fargo.mkv")
st := catchedTorrentStore("1", data, ih)
st.downloads["1"].State = store.StateFailed
st.retriedErr = errors.New("db down")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
var attrs []slog.Attr
w.log = slog.New(probeHandler{attrs: &attrs})
// Best-effort: сам retry состоялся, несмотря на отказ сброса.
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
}
assertScopeAttrs(t, attrs, ih)
})
t.Run("сброс счётчика пропусков не прошёл", func(t *testing.T) {
data, ih := buildTorrent(t, "Fargo.mkv")
st := catchedTorrentStore("1", data, ih)
st.downloads["1"].State = store.StateFailed
st.downloads["1"].SourceMissCount = 3
st.missCntErr = errors.New("db down")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
var attrs []slog.Attr
w.log = slog.New(probeHandler{attrs: &attrs})
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
assertScopeAttrs(t, attrs, ih)
})
}
// assertScopeAttrs проверяет, что scoped-логгер загрузки собран и несёт
// корреляционные поля.
func assertScopeAttrs(t *testing.T, attrs []slog.Attr, infohash string) {
t.Helper()
got := map[string]string{}
for _, a := range attrs {
got[a.Key] = a.Value.String()
}
if got["download_id"] != "1" {
t.Errorf("download_id = %q, want 1", got["download_id"])
}
if got["infohash"] != infohash {
t.Errorf("infohash = %q, want %q", got["infohash"], infohash)
}
}
+13 -10
View File
@@ -961,7 +961,8 @@ func (w *Worker) logCmd(ctx context.Context, cmd, id string, err error) {
log := logctx.FromOr(ctx, w.log) log := logctx.FromOr(ctx, w.log)
switch { switch {
case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, ErrInvalidInput), case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, ErrInvalidInput),
errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision): errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision),
errors.Is(err, layout.ErrNameTooLong):
log.Debug("command rejected", "command", cmd, "download_id", id, "error", err) log.Debug("command rejected", "command", cmd, "download_id", id, "error", err)
default: default:
log.Error("command failed", "command", cmd, "download_id", id, "error", err) log.Error("command failed", "command", cmd, "download_id", id, "error", err)
@@ -1035,6 +1036,12 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
if d.State != store.StateFailed && d.State != store.StateStuck { if d.State != store.StateFailed && d.State != store.StateStuck {
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable: %w", id, d.State, ErrConflict) return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable: %w", id, d.State, ErrConflict)
} }
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity). Иначе запись клиента qBittorrent об
// отказе добавления уходит без download_id/infohash, а причины отказа
// qBittorrent не сообщает («Fails.») — корреляция там единственное, что
// делает запись пригодной для разбора. Форма та же, что у Delete.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
// Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней, // Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней,
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add — когда // повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add — когда
// источника в qBittorrent нет. Базис таймаута сбрасывается ниже через // источника в qBittorrent нет. Базис таймаута сбрасывается ниже через
@@ -1076,8 +1083,7 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
// Байты torrent недоступны — откатываем активацию, задача не должна // Байты torrent недоступны — откатываем активацию, задача не должна
// «качаться» без раздачи в qBittorrent. // «качаться» без раздачи в qBittorrent.
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil { if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
w.log.Error("retry rollback failed", logctx.From(ctx).Error("retry rollback failed", "error", rbErr)
"capability", capReview, "download_id", id, "error", rbErr)
} }
return fmt.Errorf("retry: prepare add: %w", prepErr) return fmt.Errorf("retry: prepare add: %w", prepErr)
} }
@@ -1085,8 +1091,7 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
// Активация уже прошла — откатываем задачу в прежнее состояние, // Активация уже прошла — откатываем задачу в прежнее состояние,
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent. // чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil { if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
w.log.Error("retry rollback failed", logctx.From(ctx).Error("retry rollback failed", "error", rbErr)
"capability", capReview, "download_id", id, "error", rbErr)
} }
return fmt.Errorf("retry: add to qbittorrent: %w", err) return fmt.Errorf("retry: add to qbittorrent: %w", err)
} }
@@ -1096,8 +1101,7 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
// stuck_after на ближайшем тике. Best-effort: сбой лишь лишает свежего окна // stuck_after на ближайшем тике. Best-effort: сбой лишь лишает свежего окна
// (WARN), сам retry уже состоялся. // (WARN), сам retry уже состоялся.
if err := w.store.SetRetriedAt(ctx, id, w.now()); err != nil { if err := w.store.SetRetriedAt(ctx, id, w.now()); err != nil {
w.log.Warn("retry basis reset failed", logctx.From(ctx).Warn("retry basis reset failed", "error", err)
"capability", capReview, "download_id", id, "error", err)
} }
// Сброс счётчика пропусков источника: retried source_gone-задача иначе вошла бы // Сброс счётчика пропусков источника: retried source_gone-задача иначе вошла бы
// в downloading с source_miss_count == threshold и упала бы снова на ближайшем // в downloading с source_miss_count == threshold и упала бы снова на ближайшем
@@ -1105,11 +1109,10 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
// обещанного грейс-окна (MAJOR-3). Best-effort: сбой лишь лишает свежего окна. // обещанного грейс-окна (MAJOR-3). Best-effort: сбой лишь лишает свежего окна.
if d.SourceMissCount != 0 { if d.SourceMissCount != 0 {
if err := w.store.SetSourceMissCount(ctx, id, 0); err != nil { if err := w.store.SetSourceMissCount(ctx, id, 0); err != nil {
w.log.Warn("retry miss count reset failed", logctx.From(ctx).Warn("retry miss count reset failed", "error", err)
"capability", capReview, "download_id", id, "error", err)
} }
} }
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition", logctx.From(ctx).Info("state transition",
"from", d.State, "to", store.StateDownloading) "from", d.State, "to", store.StateDownloading)
return nil return nil
} }
+15 -1
View File
@@ -24,6 +24,9 @@ type fakeStore struct {
transitions []transition transitions []transition
torrents map[string][]byte // download_id → байты .torrent torrents map[string][]byte // download_id → байты .torrent
promoteErr error // если задан — PromoteCatched возвращает его, НЕ меняя state (симуляция транзиентного сбоя БД) promoteErr error // если задан — PromoteCatched возвращает его, НЕ меняя state (симуляция транзиентного сбоя БД)
setStateErr error // если задан — SetDownloadState отказывает (ветка отката Retry)
retriedErr error // если задан — SetRetriedAt отказывает (best-effort сброс базиса)
missCntErr error // если задан — SetSourceMissCount отказывает (best-effort сброс счётчика)
} }
type transition struct { type transition struct {
@@ -166,6 +169,9 @@ func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string)
} }
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error { func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
if f.setStateErr != nil {
return f.setStateErr
}
d, ok := f.downloads[id] d, ok := f.downloads[id]
if !ok { if !ok {
return fmt.Errorf("download %s not found", id) return fmt.Errorf("download %s not found", id)
@@ -213,6 +219,9 @@ func (f *fakeStore) SetParsedContext(_ context.Context, id, jsonStr string) erro
} }
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error { func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
if f.missCntErr != nil {
return f.missCntErr
}
d, ok := f.downloads[id] d, ok := f.downloads[id]
if !ok { if !ok {
return fmt.Errorf("download %s not found", id) return fmt.Errorf("download %s not found", id)
@@ -233,6 +242,9 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time)
} }
func (f *fakeStore) SetRetriedAt(_ context.Context, id string, t time.Time) error { func (f *fakeStore) SetRetriedAt(_ context.Context, id string, t time.Time) error {
if f.retriedErr != nil {
return f.retriedErr
}
d, ok := f.downloads[id] d, ok := f.downloads[id]
if !ok { if !ok {
return fmt.Errorf("download %s not found", id) return fmt.Errorf("download %s not found", id)
@@ -283,6 +295,7 @@ type fakeQbt struct {
torrentsErr error torrentsErr error
onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read) onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
added []qbt.AddRequest added []qbt.AddRequest
addCtx []context.Context // ctx каждого вызова Add (проверка scoped-логгера)
addErr error addErr error
onAdd func() // вклинивается в момент Add (симуляция отмены в окне после add) onAdd func() // вклинивается в момент Add (симуляция отмены в окне после add)
files []qbt.File files []qbt.File
@@ -321,7 +334,8 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
return out, nil return out, nil
} }
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error { func (f *fakeQbt) Add(ctx context.Context, ar qbt.AddRequest) error {
f.addCtx = append(f.addCtx, ctx)
if f.onAdd != nil { if f.onAdd != nil {
f.onAdd() f.onAdd()
} }
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-06
@@ -0,0 +1,284 @@
## Context
Аудит capability `state-reconciliation` (сверка код↔спека) нашёл расхождение в
требовании «Ручное закрытие загрузки (стоп-кран)». Спека утверждает единый путь
закрытия из любого состояния кроме `deleted`, помечаемый
`error_code = "user_dismiss"`. Код даёт **два** пути, разведённые по состояниям.
Что показало чтение кода (провенанс каждого факта — путь:строка):
| Факт | Где |
| --- | --- |
| `Cancel` отклоняет терминальные состояния, ставит пустые `error_code`/`error_msg` | `internal/worker/worker.go:973` |
| `Dismiss` доступен из любого состояния кроме `deleted`, ставит `user_dismiss`, из `cancelled` — no-op без записи | `internal/worker/worker.go:1001` |
| Веб-UI: «Закрыть» только для терминальных, кроме `deleted`/`cancelled` | `internal/httpapi/download.go:129` |
| Веб-UI: «Отменить» только для не-терминальных | `web/templates/partials/download_main.html:26`, `partials/card.html:32` |
| Экран ревью: «Отклонить» → `Cancel` | `web/templates/partials/review_main.html:77` |
| Telegram: «Закрыть» на `failed`/`stuck` и на `done`/`orphaned`/`target_missing` | `internal/tgbot/render.go:271`, `:290` |
| Telegram: «Отклонить» → `Cancel` на карточке ревью | `internal/tgbot/render.go:119`, `bot.go:325` |
| Telegram: `downloading`/`recognizing`/`linking`/`completed`/`catched` — карточка без действий (только ссылка в веб) | `internal/tgbot/render.go:39` |
| REST: есть `cancel` и `retry`, `dismiss` нет | `internal/httpapi/httpapi.go:152` |
| `failed` терминален, `stuck` — нет | `internal/store/download.go:54` |
| Граф переходов уже описывает пару `Cancel`/`Dismiss` прозой | `internal/store/download.go:86` |
Важное следствие таблицы: постановка задачи описывала разрыв как
«веб-UI против домена», а он шире — раскладка путей **разная у разных
поверхностей**. Telegram даёт `Dismiss` из `stuck`, то есть из не-терминального
состояния, а на `review` даёт `Cancel`; веб-UI режет строго по терминальности.
Ограничение по постановке: **кода изменение не трогает** — критерий приёмки
задачи требует пустого дифа под `internal/` и `web/`.
## Goals / Non-Goals
**Goals:**
- Нормативный дом поведения перестаёт утверждать то, чего поверхности не дают.
- Требование называет `error_code` на каждом пути закрытия, а не на одном.
- Требование называет гарантию, которая действительно держится: закрытие
доступно из любого не-`deleted` состояния хотя бы одной поверхностью.
- Разрыв наблюдаемости назван с ценой, чтобы следующий аудит не открывал его
заново.
**Non-Goals:**
- Не менять поведение: `Dismiss` из не-терминальных состояний веб-UI не
зовётся, гейт `Dismissable` остаётся как есть.
- Не заводить второй ADR о приёме «спека следует за кодом» — приём уже описан
в [ADR-2026-08-06-spec-follows-code-on-narrow-window](../../../docs/adr/ADR-2026-08-06-spec-follows-code-on-narrow-window.md).
- Не переписывать требование `review` о команде «Отклонить»: его предмет —
экран ревью, а не закрытие как класс.
- Не заводить `dismiss` в REST: REST помечен опциональным транспортом.
## Decisions
### D1. Спека двигается к коду — но только в той половине, где приём ADR применим
Приём и его границы заданы ADR-2026-08-06: двигается тот, **чья формулировка
сильнее рационали**. Требование содержит **два** обещания, и приём применим
только к одному из них — ревью предложения показало, что первая редакция этого
не различала.
- **Обещание доступности** («закрыть можно любую загрузку кроме `deleted`»).
Рациональ кодом выполнен: страница загрузки веб-UI даёт путь закрытия из
каждого не-`deleted`, не-`cancelled` состояния. Формулировка «команда
`dismiss` из любого состояния во всех транспортах» была сильнее рационали и
требовала от каждой поверхности **обе** кнопки. Здесь спека и правда
двигается к коду.
- **Обещание различимости** («закрытие помечено `user_dismiss`»). Рациональ —
различать инициатора в диагностике — кодом **не** достигнут, а по правилу
того же ADR недостающая гарантия чинится кодом. Поэтому спека этот пункт
**не закрепляет**: сегодняшняя пустота кода на пути `Cancel` описана как
факт, а не как `SHALL`, и вопрос оставлен открытым (D7).
Альтернатива C (оставить всё как есть) отвергается по тому же основанию, что в
ADR: расхождение воспроизводится каждым аудитом, а читатель спеки считает
маркер гарантированным.
### D2. Требование разводит доменную команду и обязательства поверхностей
Прежний текст смешивал два уровня в одном предложении («команда доступна из
любого состояния во всех транспортах»). Новый текст разводит: нормативны
**гарантия закрытия** (страница загрузки веб-UI даёт путь из каждого
не-`deleted`/`cancelled` состояния) и **гейты доменных путей** (`Cancel`
не-терминальные, `Dismiss` — всё кроме `deleted`). Раскладка кнопок по
поверхностям нормативной **не объявляется**: она описана как сегодняшнее
состояние, перечислением состояний, а не оборотом «там, где карточка вообще
даёт действия».
Первая редакция закрепляла раскладку через `SHALL`, и ревью предложения нашло
в этом две беды разом: она противоречила заявке этого же решения («спека
фиксирует гарантию, не вёрстку»), а Telegram-половина формулировки была
круговой — истинной при любом поведении кода, включая удаление кнопки.
Описательная форма с перечнем состояний фальсифицируема (по ней строится
таблица «состояние × поверхность») и не превращает будущее выравнивание
поверхностей в нарушение спеки.
Альтернатива — перечислить в спеке точную раскладку клавиатур Telegram —
отклонена: спека стала бы копией `render.go` и краснела бы от косметической
правки кнопки.
### D3. `error_code` называется на каждом пути; нормативен только `user_dismiss`
Пустой код — тоже наблюдаемое обязательство, и умолчание о нём породило задачу.
Пишем прямо: `Dismiss``"user_dismiss"` (нормативно), `Cancel` → сегодня
пусто (описательно, см. D1 и D7). К этому добавлено то, чего в старом тексте не
было вовсе: **оба** пути замещают прежнюю диагностику записи. Первая редакция
приписывала затирание только `Cancel` и тем создавала ложное впечатление, что
маркерный путь причину сохраняет; `worker.go:1016` пишет `user_dismiss` поверх
`qbit_error` ровно так же.
### D4. Ограничение названо в теле требования, а не спрятано в design
Форма взята из ADR-2026-08-06: молчащая ложная гарантия дороже названного
ограничения. Абзац «известное ограничение» стоит в самом требовании, потому что
архив change'а читают реже, чем спеку.
### D5. `review` не трогаем
Команда «Отклонить» описана в `review` («Команды ревью и их эффекты»,
`review/spec.md:33`, `:370`) — там её дом. Здесь она упоминается как **один из
путей закрытия** со ссылкой на состояние, но требование `review` не
переписывается: иначе у одного факта появилось бы два нормативных дома.
Цена решения названа честно: `error_code` пути «Отклонить» теперь стоит в
`state-reconciliation`, а сама команда описана в `review`. Разнесение
оправдано тем, что предмет здесь — **закрытие как класс**, и сравнение двух
путей имеет смысл только рядом.
### D6. Сценарии добавляются на оба новых утверждения
Постановка требовала сценарий закрытия не-терминальной загрузки из веб-UI.
Добавляются четыре: веб-UI не-терминальная (пустой код), терминальная (маркер +
отказ `Cancel`), один и тот же `stuck` с двух поверхностей (маркер зависит от
поверхности), замещение прежней диагностики стоп-краном. Плюс переписан
сценарий повторного закрытия `cancelled` (no-op без записи **и** отказ `Cancel`
конфликтом).
Выброшены два кандидата первой редакции. Сценарий «Закрытие зависшей загрузки»
с `GIVEN stuck (или failed/deferred)` обещал пользовательский путь «Закрыть»
для `deferred`, которого нет ни в одной поверхности, — ровно та ложная
гарантия, ради снятия которой задача и заведена. Сценарий «Отклонение на ревью
из Telegram» привязывал норму к кнопке карточки ревью, то есть заводил второй
дом факту, живущему в `review` (D5), и дублировал сценарий веб-UI.
### D7. Собственный код у `Cancel` — открытый вопрос, а не отвергнутый вариант
Первая редакция обосновывала потерю наблюдаемости так: «единый маркер
потребовал бы звать `Dismiss` из не-терминальных состояний веб-UI, то есть
менять рабочее поведение». Архитектурный проход показал, что это ложное
основание: рассматривались вариант A (звать `Dismiss` из веб-UI — да, меняет
поведение) и вариант C (ничего не делать), а **третья форма не
рассматривалась** — дать `Cancel` собственный непустой код (`user_cancel`).
Она меняет одну строку `worker.go:985`, не трогает ни состояний, ни файлов, ни
интерфейса, и восстанавливает различение полностью.
Эта форма выходит за объявленную границу задачи: критерий приёмки A3 требует
пустого диффа под `internal/` и `web/`. Решать её не пайплайну — вопрос
записан наружу (см. «Open Questions»), а изменение урезано до остатка:
**спека перестаёт лгать и перестаёт цементировать пустоту**. Отсюда описательная
форма в D3 — будущая одностроковая починка не станет нарушением спеки и не
потребует нового change.
Заодно назван настоящий масштаб потери, которого постановка не видела: маркер
сегодня кодирует **поверхность**, а не намерение. Один и тот же `stuck`,
закрытый из Telegram, получает `user_dismiss`, а закрытый из веб-UI — пустой
код. Это зафиксировано сценарием, чтобы вопрос не пришлось открывать заново.
### D9. Исключение про уборку торрента висит на состоянии, а не на команде
Правка чекпоинта 2. Редакция D8 повесила carve-out на `Cancel` — и промахнулась
осью. Уборка в `worker.go:534` срабатывает по условию `after.State !=
StateCatched`, то есть безразлична к тому, какая команда увела задачу из
`catched`; `Dismiss` из `catched` даёт тот же `torrents/delete` с
`deleteFiles=true`. Проход `adversary` воспроизвёл это тестом
(`TestDismissInAddWindowCallsQbitDelete`). Гейт `Dismiss` при этом **шире**
гейта `Cancel` — значит формулировка «`Dismiss` не зовёт qBittorrent никогда»
была ложной ровно там, где ошибиться дороже всего.
Исключение переформулировано по условию («закрытие любым путём, уведшее задачу
из `catched` в окне после `add`») — как оно и записано в `download-tracking`,
командо-нейтрально.
Заодно убрано утверждение «и данных пользователя не касается». Оно сильнее того,
что гарантирует дом исключения: гард подтверждает отсутствие **записи торрента**
в qBittorrent, но не отсутствие **данных** на диске, и `adversary` построил путь,
на котором сносятся пред-существующие файлы пользователя
(`TestCancelInAddWindowDeletesPreExistingData`). Сам этот дефект — в коде и
пред-существующий; здесь снимается только ложная гарантия в тексте, а дефект
уходит урожаем.
### D10. Перепривязка названа не-лекарством
Правка чекпоинта 2. Первая редакция писала, что закрытие оставляет ссылки «без
штатной команды снятия **до повторной привязки**» — то есть подавала relink
лекарством. `adversary` показал тестами (`TestDismissRelink`,
`TestStrandedLink`), что это не так: `Apply` заводит новый батч, а `Undo` и
`Delete` работают только с последним, поэтому ссылка прежнего батча брошена
навсегда, и последующее «полное удаление» перестаёт освобождать место. Текст
приведён к факту; расширение `Undo`/`Delete` на все батчи — урожай.
### D11. Подтверждение и лог перехода описаны как есть, а не как хотелось
Правка чекпоинта 2. Два новых обязательства первой редакции въезжали уже
нарушенными:
- «SHALL требовать отдельного подтверждающего шага» — без JS диалога в веб-UI
нет (гейт там только раскрытие `<details>` и явный submit), а в Telegram нет
danger-зоны. Норма сужена до фактического гейта: обособление плюс
дополнительный шаг там, где транспорт его поддерживает.
- «прежний код остаётся в логе перехода» — лог-строки `Cancel` и `Dismiss`
прежнюю диагностику не пишут вовсе (`worker.go:985-989`, `:1016-1020`).
Формулировка исправлена: причина восстановима только по более раннему
переходу в `failed`/`stuck` и только пока её держит ретенция логов.
Оба раза выбран вариант «привести текст к факту», а не «оставить `SHALL` целью»:
нормативный дом с заведомо невыполненным `SHALL` — это ровно тот дефект, ради
снятия которого заведена задача.
### D8. Отрицание побочных эффектов сужено до `Dismiss` с явным исключением
Первая редакция обобщила «команда SHALL только менять статус, qBittorrent не
звать» с `Dismiss` на **оба** пути — и тем самым нормативно запретила уборку
воркером собственного, только что добавленного торрента при отмене в окне после
`add`. Эта уборка предписана `download-tracking` («Добавление пойманной
загрузки в qBittorrent»), закреплена тестом
`TestProcessCatchedCancelledAfterAddRemovesTorrent` и стоит вторым исключением
инварианта «источник неприкосновенен» в `CLAUDE.md`. Изменение, чья цель —
снять ложную гарантию, чуть не завело новую, да ещё и противоречащую соседней
capability.
Отрицание возвращено к `Dismiss`, а для `Cancel` дано с явной ссылкой на дом
исключения. Общее правило, которое отсюда следует: универсальное «SHALL NOT
трогать X» обязано перечислять известные carve-out'ы или ссылаться на
требование, где они живут.
## Risks / Trade-offs
- **[Асимметрия поверхностей описана и тем самым узаконена, а завтра её
захотят выровнять]** → Описательная форма (D2) выравнивание не блокирует:
спека не станет ложной от того, что веб-UI добавит «Закрыть» на `stuck`.
Нормативны только гарантия закрытия и гейты доменных путей.
- **[Прецедент «спека следует за кодом» применяется буквально]** → Риск
унаследован от ADR-2026-08-06 и там же оговорён. Условие приёма проверено
поимённо и выполнено **не целиком**: для обещания доступности — да, для
обещания различимости — нет, поэтому второе спекой не закрепляется (D1, D7).
Это и есть защита от буквального применения.
- **[Новые сценарии не имеют теста-оракула]** → Изменение чисто текстовое,
гейт его регрессию не покрасит. Оракулом остаётся `openspec validate
--strict` (форма) и чтение кода (содержание). Это идёт в границы покрытия
ревью, а не замалчивается. Отдельно: клавиатуры Telegram не покрыты тестами
вовсе, поэтому описанная в требовании раскладка по этой поверхности держится
только на чтении `render.go`.
- **[Требование выросло в объёме]** → Выросло сознательно: раньше оно было
короче своей же предметной области. Раскладка клавиатур в него не заехала
(D2), поэтому косметическая правка UI его не тронет.
- **[Часть найденного ревью не закрыта этим изменением]** → Отдано урожаем, а не
замолчано (полный список с оракулами — в `review/triage.md`): гард окна после
`add` подтверждает отсутствие торрента, но не данных, и сносит
пред-существующие файлы пользователя (кандидат `critical`, воспроизведён
тестом); `Undo`/`Delete` работают только с последним батчем; прежняя
диагностика не дублируется в лог перехода; «per-download блокировка» — на
деле глобальный мьютекс, файловый ввод-вывод идёт под ним; дефолтный список
даёт `SCAN download`, а `cancelled` копятся без ретеншена; та же no-JS-дыра
в пред-существующем требовании «Полное удаление»; гонка «кнопка отрисована —
состояние ушло» не описана ни одним требованием проекта; литералы `error_code`
рассыпаны по трём спекам без сводной таблицы.
- **[Кросс-ссылки в `openspec/specs/` никем не проверяются]** → Шаг `canon`
гейта обходит только `docs/` и `CLAUDE.md`. Первая редакция принесла в спеку
markdown-ссылку, не резолвившуюся ни из change, ни после архивации; она
заменена бэктик-путём — это и есть сложившаяся в `openspec/specs/` конвенция
(markdown-ссылок там нет ни одной). Promote-кандидат отдан урожаем.
## Open Questions
- **Дать ли `Cancel` собственный непустой `error_code`** (напр. `user_cancel`).
Цена — одна строка `worker.go:985` плюс строка в `docs/database.md`; выигрыш —
различение «пользователь отменил активную» / «стоп-кран» / «сверка» в
диагностике восстанавливается полностью, и «известное ограничение» из
требования исчезает. Не делается здесь: выходит за границу задачи (критерий
приёмки A3 — пустой дифф под `internal/`). Пока решения нет, стоит то, что
описано: маркер кодирует поверхность, а не намерение. Рекомендация — завести:
цена несоразмерно мала.
- Стоит ли давать `dismiss` в REST ради равенства транспортов. Сегодня REST
помечен опциональным и отдаёт только `cancel`; вопрос не блокирует изменение.
@@ -0,0 +1,68 @@
## Why
Требование «Ручное закрытие загрузки (стоп-кран)» обещает команду «Закрыть»
(dismiss) «из **любого** состояния, кроме `deleted`, во всех транспортах» и
метку `error_code = "user_dismiss"` на переходе. Доменная команда
`worker.Dismiss` это выполняет, но **поверхности** её так не показывают:
веб-UI держит «Закрыть» в danger-зоне только для терминальных состояний
(`Dismissable = IsTerminal() && !deleted && !cancelled`), а закрытие
не-терминальной загрузки идёт кнопкой «Отменить»/«Отклонить» → `worker.Cancel`,
которая пишет **пустой** `error_code`. Telegram показывает «Закрыть» ещё по
третьей раскладке — для `failed`/`stuck` и для `done`/`orphaned`/
`target_missing`, а на `review`/`deferred` даёт «Отклонить» → `Cancel`.
Расходится буква, а не поведение: `Cancel` тоже даёт `cancelled`, файлы и
раздачу не трогает, семантика для пользователя та же. Теряется маркер
`user_dismiss` в диагностике — по `error_code` не отличить «пользователь закрыл
активную» от «пользователь отменил». Пока спека утверждает единый путь закрытия,
разрыв невидим и находится заново каждым аудитом capability.
## What Changes
Меняется **заявленное**, не наблюдаемое. Кода изменение не трогает.
- Требование «Ручное закрытие загрузки (стоп-кран)» разводит два уровня:
**гейты доменных путей** (`Cancel` — не-терминальные, `Dismiss` — любое кроме
`deleted`; это нормативно) и **раскладку кнопок по поверхностям** (описана как
сегодняшнее состояние, нормативной не объявляется).
- Требование называет `error_code` на каждом пути закрытия: `Dismiss`
`"user_dismiss"` (нормативно), `Cancel` (отмена активной / отклонение на
ревью) → сегодня пустой (описательно — собственный код у `Cancel` не
запрещён, вопрос открыт). Прежняя формулировка обещала `user_dismiss` на
любом закрытии.
- Требование называет гарантию, которая держится: страница загрузки веб-UI даёт
путь закрытия из каждого не-`deleted` и не-`cancelled` состояния; какой именно
путь — выбор поверхности, а не обязательство домена.
- Отрицание побочных эффектов остаётся при `Dismiss`; для `Cancel` названо
единственное исключение — уборка воркером собственного, только что
добавленного торрента при отмене в окне после `add` (дом исключения —
`download-tracking`).
- Названо, что **оба** пути замещают прежнюю диагностику записи, а не только
`Cancel`.
- Добавляются сценарии закрытия не-терминальной загрузки из веб-UI, закрытия
терминальной, замещения диагностики и расхождения маркера по поверхностям.
- Названо известное ограничение: маркер сегодня кодирует поверхность, а не
намерение; разрыв описан с ценой, а не замолчан.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `state-reconciliation`: требование «Ручное закрытие загрузки (стоп-кран)» —
доступность закрытия по состояниям и поверхностям, `error_code` на каждом
пути закрытия, сценарии закрытия не-терминальной загрузки.
## Impact
- `openspec/specs/state-reconciliation/spec.md` — одно требование и его
сценарии.
- `internal/httpapi/download.go`, `web/templates/partials/download_main.html`,
`web/templates/partials/card.html`, `internal/tgbot/render.go`,
`internal/worker/worker.go`**только чтение**, правок не предполагается.
Отсутствие правок под `internal/` и `web/` — критерий приёмки задачи.
- Прецедент приёма — [ADR-2026-08-06-spec-follows-code-on-narrow-window](../../../docs/adr/ADR-2026-08-06-spec-follows-code-on-narrow-window.md);
второй ADR о том же не заводится.
@@ -0,0 +1,367 @@
# Триаж чекпоинта 2 — change `cancel-dismiss-split-wording`
Дата: 2026-08-06. Профиль: `standard`, режим: `по графу`.
База диффа: `01e64d60de7495900d9a4c8f5ce9bf811ee7b7e6`.
## Сводка
**Гейт:** зелёный. Кодовые шаги (build/vet/lint/gofmt/test/flaky/race/
diff-coverage/migrations/govulncheck) — `SKIP` с причиной «кода в диффе нет»;
`canon``OK`; `gitleaks``OK`.
**Проходы поимённо:**
| Проход | Исход |
|---|---|
| `review-gate` | зелёный (кодовые шаги SKIP — кода в диффе нет) |
| `review-specs` | отработал, 4 находки + 4 строки границ |
| `review-code` | отработал, 1 находка + подтверждение A3 + блок «нарушений нет» |
| `review-adversary` | отработал, 5 находок (4 воспроизводящих теста), 3 свойства без пути |
| `review-reimpl` | **не запускался** — стадия 3, только `deep`; вне профиля `standard` |
| `review-architecture` | **не запускался на этом чекпоинте** — стадия 4 (`wide`/`deep`); отработал на чекпоинте 1 в профиле `design`, находки закрыты правками предложения (design.md, D1D3, D6D8) |
| `review-ops` | отработал, 3 находки + ответы на обязательные вопросы |
Состав запущенного сверен с профилем `standard` — расхождений нет.
**Счёт находок:** на входе 13 (S1S4, C2, A1, A2, A3adv, A4, A5, O1O3; C1 —
подтверждение приёмки, не находка). После дедупа — 10 (S1=A3adv, C2=A4,
S4=A5). Разложено: 3 блокируют, 4 стоит исправить сейчас, 0 гипотез,
6 в урожай, 1 promote-кандидат.
**Состояние change:** файлы staged, но не закоммичены (`HEAD` == база; дифф
worktree против базы — 5 файлов, 514 вставок, все под
`openspec/changes/cancel-dismiss-split-wording/`). Перед архивацией нужен
коммит — это состояние прогона, не находка.
**Вердикт по архивации:** change **годится к архивации после инлайн-правок**
секций 1–2. Находки, требующей остановки или переработки предложения, нет:
все семь пунктов — правки текста дельты, локальные и однозначные, кроме одной
развилки (п. 7, выбор регистра нормы о подтверждении). Дефекты в коде,
вскрытые тестами adversary, — пред-существующие, изменением не введены и не
задеты (критерий A3 требует пустой кодовый дифф); они уходят урожаем.
## Приёмка A1–A3 (доклад приёмщику, не отметка «принято»)
- **A1** — требование различает `Cancel`/`Dismiss` по состояниям (не-терминальные
против «любое кроме `deleted`», конфликт против no-op) и называет `error_code`
обоих путей (`user_dismiss` против пустого). Оракул:
`openspec validate --strict cancel-dismiss-split-wording``Change … is valid`,
exit 0 (прогнано мной). **Выполнен**, с оговоркой: нормативная фраза «SHALL
называть путь закрытия» внутренне противоречит пустому коду `Cancel` — п. 4 ниже.
- **A2** — сценарий «Закрытие не-терминальной загрузки из веб-UI идёт отменой»
(дельта, строки 112–119): исход `cancelled`, `error_code` назван (пустой).
Оракул: тот же прогон + чтение сценария. **Выполнен.**
- **A3**`git diff --stat <база> -- internal web cmd`**пусто** (прогнано
мной по worktree; полный дифф — 5 файлов только под `openspec/changes/`).
Гейт `Dismissable` и danger-zone шаблона не тронуты. **Выполнен.**
---
## Блокирует мердж (архивацию)
Все три — корзина **(а): дефект введён текстом этого изменения**. Все три —
правки одного-двух абзацев дельты
`openspec/changes/cancel-dismiss-split-wording/specs/state-reconciliation/spec.md`.
### 1. Дельта гарантирует «`Dismiss` SHALL NOT вызывать qBittorrent» — гарантия ложна: `Dismiss` из `catched` в окне после `add` даёт `torrents/delete` с `deleteFiles=true`
- Файл: дельта, строки 36–47; код `internal/worker/worker.go:520556` (уборка
по условию `after.State != store.StateCatched`, безразлична к команде),
`internal/httpapi/review.go:384391` (маршрут `dismiss` по состоянию не
гейтится — путь достижим сегодня прямым POST)
- Severity: major, Confidence: high. Корзина: **(а)** — сам механизм уборки
пред-существует и нормирован командо-нейтрально в `download-tracking`
(строки 216–234); ложна только атрибуция исключения одной команде `Cancel`.
- Оракул (прогнан мной): `go test ./tmp/adv/ -run TestDismissInAddWindow -v`
«ADV-3 воспроизведён: закрытие путём Dismiss (error_code="user_dismiss")
вызвало torrents/delete deleteFiles=[true]». Плюс
`internal/worker/catched_test.go:244268` — штатный тест уборки ставит
`cancelled` напрямую, не через `Cancel`.
- Дедуп: S1 (`specs`) + A3adv (`adversary`) — одна причина: исключение в коде
привязано к состоянию/окну, а не к команде.
- Действие: **инлайн**. Переформулировать исключение по условию, а не по
команде: «закрытие **любым путём**, уведшее задачу из `catched` в окне между
нашим `add` и записью перехода» — как это уже сформулировано в
`download-tracking`. Тем же правом убрать из абзаца атрибуцию «`Cancel` тем же
ограничен, с одним исключением» — исключение общее для обоих путей.
- **В ту же правку входит C2/A4** (дедуп: одна ссылка, одно предложение):
markdown-ссылка `../../../specs/download-tracking/spec.md` (строка 44) не
резолвится **ни из позиции дельты** (нормализуется в
`openspec/changes/specs/download-tracking/spec.md`, файла нет; верно было бы
4 уровня), **ни после архивации** (из `openspec/specs/state-reconciliation/`
нормализуется в `specs/download-tracking/spec.md`; верно — 1 уровень).
Единого относительного пути, верного в обеих позициях, не существует.
Проверено арифметикой пути (`os.path.normpath` + `exists`, прогнано мной).
Фикс: дать ссылку **бэктиком**`openspec/specs/download-tracking/spec.md`
как во всех соседних кросс-ссылках capability (прецедент:
`openspec/specs/state-reconciliation/spec.md:92`); markdown-ссылок в
`openspec/specs/` нет ни одной (grep пуст), и `docs.py check` этот каталог
не проверяет — битую ссылку не покрасил бы никто.
### 2. Дельта заявляет, что уборка в окне после `add` «данных пользователя не касается» — безусловное утверждение сильнее того, что гарантирует код: пред-существующие файлы под `paths.downloads` уничтожаются
- Файл: дельта, строки 45–46; код `internal/worker/worker.go:495556` (гард
«подтверждённое отсутствие» проверяет отсутствие **торрента**, не отсутствие
**данных**)
- Severity: major (для текста; кодовый дефект — кандидат `critical`, см. урожай
У-1), Confidence: high для текстовой части. Корзина: **(а)** для текста /
**(в)** для кода — дыра в гарде пред-существует, изменением не задета; этим
change введена только ложная гарантия.
- Оракул (прогнан мной): `go test ./tmp/adv/ -run TestCancelInAddWindow -v`
«ADV-1 воспроизведён: worker вызвал torrents/delete deleteFiles=[true];
данные …/downloads/Show/e1.mkv существовали под paths.downloads ДО add
(гард проверял отсутствие торрента, а не отсутствие данных)».
- Почему текст — major, а не перенос `critical` с кода: текст сам данных не
уничтожает; но, будучи влит в нормативный дом, он узаконивает дыру — следующий
аудит поверит «данных не касается» и закроет расследование. Severity кода на
severity текста автоматически не переносится; кодовый дефект едет урожаем со
своим весом.
- Действие: **инлайн**. Убрать безусловное «и данных пользователя не касается».
Right-size замена — отослать к дому нормы без собственной гарантии
(«нормирована в `download-tracking`»), не расширяя дельту признанием дыры:
дыра — предмет задачи У-1, а не этого требования.
### 3. Обещание «библиотечные ссылки без штатной команды снятия **до повторной привязки**» ложно: перепривязка их не снимает никогда — `Undo`/`Delete` работают только с последним батчем
- Файл: дельта, строки 62–66 и сценарий 93–101; код
`internal/worker/review.go:332` (каждый `Apply` — новый батч), `:557572`
(`Undo` — только `LatestBatchID`), `:625641` (`Delete` — только
`LatestBatchID`)
- Severity: major, Confidence: high. Корзина: **(а)** для формулировки /
**(в)** для кода (поведение батчей пред-существует, изменением не задето).
- Оракул (прогнан мной): `go test ./tmp/adv/ -run 'TestDismissRelink|TestStrandedLink' -v`
«ADV-2: в библиотеке осталась неуправляемая ссылка (батч 1), которую не
снимает ни Undo, ни Delete»; «ADV-2b: запись в deleted, раздача снесена с
файлами, но в библиотеке осталась неуправляемая ссылка с живыми данными —
место не освобождено».
- Последствие текста: единственный маршрут к брошенной ссылке — ровно та связка
`Dismiss` из `done` → relink, которую дельта нормирует; «до повторной
привязки» называет relink лекарством, которым он не является, а после него
«полное удаление» перестаёт освобождать место — danger-зона обещает обратное.
- Действие: **инлайн**. Заменить «до повторной привязки» честным: «…без штатной
команды снятия; последующая перепривязка их **не** снимает — `Undo` и
`Delete` работают только с последним батчем раскладки». Расширение
`Undo`/`Delete` на все батчи — урожай У-2.
## Стоит исправить сейчас
### 4. Нормативное «`error_code` SHALL называть путь закрытия» нарушено пустым кодом `Cancel` и опровергнуто тем же документом тремя абзацами ниже
- Файл: дельта, строка 49; код `internal/worker/worker.go:985`
(`SetDownloadState(ctx, id, StateCancelled, "", "")` — проверено чтением)
- Severity: major, Confidence: high. Корзина: **(а)** — фраза введена этим
изменением.
- Оракул: код (пустой код ничего не «называет») + сам документ: строки 56–58
(«пустота кода на пути `Cancel` … не закреплена нормативно») и 73–82
(«известное ограничение»). `SHALL`, который документ сам объявляет
неудовлетворённым, — приглашение следующему аудиту завести ту же задачу.
- Действие: **инлайн**. Перевести фразу в согласованный регистр: например,
«`error_code` перехода SHALL **различать** пути закрытия, а не унифицировать
их» (пустой у `Cancel` против `user_dismiss` у `Dismiss` — различает), либо
сузить нормативность до `Dismiss` (его `SHALL user_dismiss` уже стоит строкой
ниже). Остальной абзац и «известное ограничение» уже согласованы.
### 5. «Прежняя диагностика остаётся только в логе перехода» — фактически неверно: лог-строки перехода `Cancel`/`Dismiss` прежних `error_code`/`error_msg` не содержат
- Файл: дельта, строки 53–58 и сценарий 103–110; код
`internal/worker/worker.go:985991, 10161022` — проверено чтением: `Cancel`
логирует только `from`/`to`, `Dismiss``from`/`to`/`code` **нового**
перехода; контракт поля `code``docs/conventions/logging.md:2630`.
- Severity: major, Confidence: high. Корзина: **(а)** для формулировки /
**(б)** для наблюдаемости (лог и раньше не нёс прежней диагностики — урожай
У-3).
- Последствие: причина падения восстановима только по более ранней, никак не
связанной по смыслу лог-строке, чья доступность зависит от неописанной
ретенции логов (`docs/architecture.md` — только «логи в stdout контейнера»).
- Действие: **инлайн**. Поправить формулировку: прежние код и текст «в записи
не сохраняются; лог-строка перехода их не дублирует — они остаются лишь в
более ранних строках лога состояния». Добавление `prev_error_code`/`prev_error_msg`
в лог — кодовая правка, запрещённая критерием A3, → урожай У-3.
### 6. Описанная раскладка Telegram врёт в трёх клетках построенной по ней таблицы «состояние × поверхность»
- Файл: дельта, строки 30–34; код `internal/tgbot/bot.go:452461`,
`internal/tgbot/render.go:4450, 281290` — проверено чтением: уведомление
`EventDone` — текст без клавиатуры; `EventTargetMissing`/`EventOrphaned`
только ссылка в веб (`webOnly`, и та `nil` при пустом `web_base_url`);
`StateLinking` — клавиатуры нет вовсе (не «отсылает в веб»). «Закрыть»
появляется на **карточке** (`renderCard`/refresh), а не в уведомлении.
- Severity: minor, Confidence: high. Корзина: **(а)** — перечень введён этим
изменением (текст описателен: «сегодня раскладка такова», но неверен).
- Последствие: реальный пробел — уведомление о рассинхроне приходит без
стоп-крана — текстом замаскирован.
- Действие: **инлайн**. Либо уточнить: «Telegram даёт „Закрыть“ на **карточке**
загрузки в `failed`/`stuck`/`done`/`orphaned`/`target_missing`; уведомления
о `done` и рассинхроне кнопок закрытия не несут», либо снять перечень
Telegram целиком, оставив нормативную гарантию только за страницей загрузки
(раскладка по поверхностям всё равно объявлена ненормативной).
### 7. Новый `SHALL` об «отдельном подтверждающем шаге» въезжает уже нарушенным: без JS диалога нет, в Telegram нет ни danger-зоны, ни подтверждения
- Файл: дельта, строки 68–71; код
`web/templates/partials/download_main.html:9598` — проверено чтением,
комментарий дословно: «без JS гейт — только раскрытие details и явный submit
(диалога нет)»; `internal/tgbot/render.go:266290` — «Закрыть» в общем ряду
кнопок. `openspec/specs/web-ui/spec.md` нормирует «без JavaScript не
ломается».
- Severity: minor, Confidence: medium. Корзина: **(а)** — прежняя редакция
обязательства не содержала (уточнение adversary), норма введена этим
изменением; тот же изъян в пред-существующем требовании «Полное удаление
загрузки пользователем» — корзина (в), урожай У-6.
- Дедуп: S4 (`specs`) + A5 (`adversary`) — одна причина.
- Действие: **развилка** — выбор регистра нормы, а не правка формулировки:
1. **Сузить норму до фактического гейта**: «„Закрыть“ SHALL размещаться в
обособленной danger-зоне, скрытой по умолчанию (раскрытие — сознательный
шаг); с JS дополнительно SHALL показываться диалог подтверждения» + назвать
известным ограничением, что Telegram danger-зоны не даёт. Цена: слабее
звучит R7, зато спека не лжёт и код не требуется.
2. **Оставить `SHALL` как цель** и завести урожаем задачу: no-JS-подтверждение
(например, промежуточная страница-подтверждение) и danger-гейт в Telegram.
Цена: до выполнения задачи нормативный дом содержит невыполненный `SHALL`
(ровно то, за что бьёт п. 4).
Рекомендация триажа — вариант 1: он согласуется с R12 («спека фиксирует
наблюдаемое») и с решением п. 4.
## Гипотезы без доказательства
Пусто. Все выжившие находки имеют прогнанный оракул (тест, чтение названных
строк кода, арифметика пути, вывод команды). Понижений за отсутствие оракула
не было; `critical` кода из A1 не понижен, а отделён от текстовой находки и
уехал урожаем со своим весом (У-1) — его единственный непрогнанный шаг назван
там честно.
Отдельно: совпадение S1/A3adv и S4/A5 в разных проходах учтено как рост
приоритета, не как рост Confidence — под всеми проходами одна модель.
## Promote candidates
- **Проверка ссылок в `openspec/`** — шаг `canon` (`docs.py check`) обходит
только `docs/` и `CLAUDE.md`; битую markdown-ссылку в спеке не красит никто
(п. 1/C2 прожил бы до архивации молча). Кандидат: расширить проверку битых
ссылок на `openspec/specs/` и `openspec/changes/`, либо записать конвенцией
«кросс-ссылки в спеках — только бэктиком» (сегодня это фактическая, но
нигде не записанная практика: markdown-ссылок в `openspec/specs/` ноль).
Провенанс: `review-code` C2 + `review-adversary` A4.
## Урожай (пред-существующие дефекты — владельцу задач, не в этот change)
- **У-1 (кандидат `critical`, Confidence: medium).** Отмена/закрытие в окне
после `add` сносит **пред-существующие** файлы пользователя под
`paths.downloads`: гард «подтверждённое отсутствие» (`worker.go:495509`)
проверяет отсутствие торрента в qBittorrent, но не отсутствие данных на
save_path; `qbt.Add` подхватывает лежащие там файлы, уборка бьёт
`torrents/delete` с `deleteFiles=true`. Путь: пользователь снял раздачу,
файлы оставив (это предписывает сам `download-tracking:169174` как
восстановление) → подал тот же торрент → отменил в окне листинг+add (сотни
мс — секунды). Инвариант «источник неприкосновенен»: исключение (2) покрывает
«собственный торрент», но не пред-существующие данные; UI при этом обещает
«файлы и раздача останутся нетронутыми» (`download_main.html:100111`).
Оракул: `tmp/adv/adv_test.go`, `TestCancelInAddWindowDeletesPreExistingData`
прогнан, воспроизводит (каталог gitignored — при заведении задачи тест
перенести в её материалы). Единственный непрогнанный шаг — семантика боевого
qBittorrent `deleteFiles=true` над пред-существующими файлами: в бой ходить
запрещено, отсюда medium. Провенанс: `review-adversary` A1, чекпоинт 2
change `cancel-dismiss-split-wording`.
- **У-2 (major, Confidence: high).** `Undo`/`Delete` работают только с
последним батчем (`review.go:557572, 625641`), каждый `Apply` создаёт новый
(`review.go:332`): связка «закрыть `done` → relink» бросает ссылку батча 1
навсегда, а «полное удаление» после этого не освобождает место (инод жив за
счёт брошенной ссылки). Оракул: `TestDismissRelinkStrandsLibraryLinks`,
`TestStrandedLinkDefeatsDelete` — прогнаны, воспроизводят. Провенанс:
`review-adversary` A2.
- **У-3 (minor, наблюдаемость).** Лог-строки переходов `Cancel`/`Dismiss` не
несут прежних `error_code`/`error_msg`, хотя `d` их держит в момент записи
(`worker.go:985991, 10161022`): «почему задача упала» после закрытия не
восстановить одной строкой. Кандидат: `prev_error_code`/`prev_error_msg` в
лог перехода. Оракул: чтение кода + контракт `logging.md:2630`. Провенанс:
`review-ops` O1.
- **У-4 (minor).** «Per-download блокировка» из `CLAUDE.md` (инвариант
«переходы состояний») и `worker.go:11` — фактически один глобальный
`sync.Mutex` (`worker.go:239`), удерживаемый `Apply` на весь файловый
ввод-вывод: долгая раскладка блокирует команды по **другим** загрузкам и
поллинг. Минимум — поправить формулировку в двух местах; по существу —
per-id блокировка либо вынос I/O из-под замка. Оракул: чтение кода (одно
поле, не карта). Провенанс: `review-ops` O2.
- **У-5 (minor, эффект — гипотеза при сегодняшнем профиле «единицы/день»).**
Дефолтный список — `SCAN download` (негативный `state NOT IN` мимо
`idx_download_state`, измерено `EXPLAIN QUERY PLAN` на копии схемы), а
`cancelled` копятся без ретенции, и требование под ревью расширяет канал их
производства. Частичный индекс или позитивный список состояний; ретеншен —
задача в беклоге уже есть. Провенанс: `review-ops` O3.
- **У-6 (minor).** Пред-существующее требование «Полное удаление загрузки
пользователем» (`openspec/specs/state-reconciliation/spec.md:463465`) несёт
ту же формулировку о подтверждающем шаге с той же no-JS-дырой, что п. 7.
Судьба зависит от развилки п. 7 — править согласованно. Провенанс:
`review-specs` S4 (смягчающее наблюдение).
Уже назначено урожаем ранее (не дублирую задачей): R10 из рубрики чекпоинта 1 —
«допустимость команды переоценивается на момент исполнения, а не по снимку
рендера» (`tasks.md`); туда же примыкает граница `specs` про `/review/{id}`,
открытый для любого состояния.
## Границы покрытия
**Запускалось:** профиль `standard`, режим «по графу», чекпоинт 2 (после
apply). `review-gate` (кодовые шаги SKIP — кода в диффе нет; canon, gitleaks —
OK), `review-specs`, `review-code`, `review-adversary`, `review-ops`.
**Не запускалось:** `review-reimpl` — стадия 3, только в `deep`;
`review-architecture` — стадия 4 (`wide`/`deep`); оба вне профиля `standard`.
`review-architecture` отработал на чекпоинте 1 (профиль `design`) вместе с
`review-specs` и `review-rubric`; его находки закрыты правками предложения
(design.md, решения D1D3, D6–D8) и на этом чекпоинте не перепроверялись.
**Что запущенные проходы не могли проверить в принципе (из charter'ов):**
- `specs`: поведение живых qBittorrent/Telegram — клавиатуры Telegram не
покрыты тестами вовсе, сверка была чтением кода; плюс его заявленные границы:
владение целевым путём после закрытия `done` (ушло в У-2), `/review/{id}`
без гейта состояния (примыкает к R10), «активный список» как три разных
множества (`store/list.go`, `store/download.go`).
- `code`: только записанные конвенции; конвенции о форме OpenSpec-спек в
`docs/conventions/` нет — норму задавали `openspec/config.yaml` и `CLAUDE.md`.
- `adversary`: семантика **боевого** qBittorrent (`deleteFiles=true` над
пред-существующими файлами) — в бой ходить запрещено; это единственный
непрогнанный шаг У-1. Свойства без пути: снятие последней копии
последовательностью команд не строится; секретов в `error_msg`/логах не
найдено; негейченный маршрут `dismiss` в сегодняшнем периметре
(`docs/security.md`) — операторское действие, не дефект (совпадает с
«Типовым ложноположительным» про отсутствие авторизации — корректно не
выведен находкой).
- `ops`: `-race` на этом диффе не гонялся (кода нет — SKIP); гонки суждены
рассуждением. Ретенция логов stdout нигде не описана — доступность «более
ранней лог-строки» из У-3 неизвестна.
- триаж (я): нового не ищу по определению — работаю с чужими выводами; пропуск
любого прохода — мой пропуск тоже. Тесты `tmp/adv/` прогнаны и воспроизводят,
но их фикстуры — фейковый qbt и локальная ФС: они доказывают поведение
**нашего** кода, не связки с боем.
**Осталось целиком на человеке** (из `docs/review.md`, два списка раздельно):
*Не проверит ни один проход:*
- история инцидентов на umbar и что уже ломалось в проде;
- поведение SQLite под реальным объёмом и профилем нагрузки;
- завязка внешних потребителей (Jellyfin, закладки, чужие ссылки) на текущее
поведение;
- качество распознавания (корпус решено не собирать, REJECTED 2026-08-06);
- суждение «этой функциональности не должно существовать».
*Перестали проверять сознательно:*
- идиоматичность Go — с 2026-08-04, вместе с упразднением прохода `idiom` при
переезде на плагин; различение «идиоматично против распространено» не
спрашивает никто; пересмотр — задача `quality-review-agents`.
**Каких документов/фактов не хватило (поразрядно, с причиной):**
- `docs/conventions/` — конвенции о форме OpenSpec-спек нет: правило «ссылки
бэктиком, не markdown» пришлось выводить grep'ом по фактике (`review-code`);
- `docs/architecture.md` — ретенция логов не описана (только «stdout
контейнера»): судьба «прежней диагностики в логе» из У-3 неопределима
(`review-ops`);
- `docs/review.md`, журнал дефектов — пуст (заведён 2026-07-23): оракулов
«такое здесь уже воспроизводилось» не было ни для одной находки, все
подтверждения добывались тестами и чтением кода (триаж);
- `CLAUDE.md`, инварианты — присутствуют и использованы (ранжирование У-1 по
«необратимо» и границе исключения (2)); «Типовые ложноположительные» и оба
подраздела «Недоступно проверке» — присутствуют и использованы. Здесь
пробелов нет.
@@ -0,0 +1,170 @@
## MODIFIED Requirements
### Requirement: Ручное закрытие загрузки (стоп-кран)
Система SHALL давать пользователю возможность **закрыть** загрузку — перевести
её в терминальное `cancelled`, убрав из активного списка/внимания, — из
**любого** состояния, кроме `deleted`. Страница загрузки веб-UI
(`/download/{id}`) SHALL предоставлять путь закрытия из каждого не-`deleted` и
не-`cancelled` состояния; список загрузок (`/`) ограничиваться действиями над
активными загрузками MAY. Загрузку в `deleted` закрывать система SHALL NOT
(состояние строго терминально).
Путей закрытия **два**, и они разведены гейтом терминальности:
- **«Отменить»/«Отклонить»** (`Cancel`) — отмена **активной** работы. Команда
SHALL быть доступна только из не-терминальных состояний и SHALL отклоняться
с конфликтом из терминальных, включая `cancelled`.
- **«Закрыть»** (`Dismiss`) — стоп-кран для загрузки, которую уже никто не
двигает: зависшей, спорной или лишней (в т.ч. дубля-близнеца в
`target_missing`, чьи файлы разложены другой загрузкой). Команда SHALL быть
доступна из любого состояния, кроме `deleted`, включая терминальные, где
`Cancel` уже отказывает. В `cancelled` `Dismiss` SHALL быть идемпотентным
no-op **без записи состояния** — иначе он подменил бы `error_code` прежнего
закрытия.
Раскладка путей по поверхностям нормативной **не является**: поверхность
выбирает путь по состоянию, и требовать от каждой поверхности **оба** пути
система SHALL NOT. Сегодня раскладка такова: страница загрузки веб-UI даёт
«Закрыть» в danger-зоне из терминальных состояний, кроме `deleted` и
`cancelled`, а из не-терминальных закрывает кнопкой «Отменить» (на экране
ревью — «Отклонить»); Telegram даёт «Закрыть» из `failed`, `stuck`, `done`,
`orphaned`, `target_missing`; «Отклонить» — на карточке ревью
(`review`/`deferred`). Кнопка закрытия в Telegram живёт на **карточке** задачи;
уведомления о готовности и о рассинхроне её сегодня не несут, а из `linking`
карточка не даёт действий вовсе. REST закрытие предоставлять MAY (сегодня
отдаёт только `cancel`).
Ни один путь закрытия SHALL NOT производить действий с файлами или библиотечными
ссылками: система SHALL NOT удалять либо создавать хардлинки под
`paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие библиотечные
ссылки сознательно остаются на месте. qBittorrent закрытие вызывать SHALL NOT —
раздача не снимается и продолжает раздаваться, — с **одним** исключением, и оно
привязано к состоянию, а не к команде: если закрытие **любым** путём увело
задачу из `catched` в окне между нашим `add` и записью перехода, worker убирает
только что добавленный торрент. Уборка нормирована в
`openspec/specs/download-tracking/spec.md`, требование «Добавление пойманной
загрузки в qBittorrent», и её границы задаёт оно — здесь они не пересказываются
и не расширяются. Синхронный source-preflight ни один из путей выполнять SHALL
NOT (источник в действии не участвует).
`error_code` перехода SHALL **различать** пути закрытия, а не унифицировать их.
`Dismiss` SHALL помечать переход `error_code = "user_dismiss"`
(человекочитаемая причина — в `error_msg`), отличая стоп-кран от отклонения на
ревью, от отмены активной работы и от удаления. Оба пути при этом SHALL замещать
прежнюю диагностику записи: `Dismiss` — на `user_dismiss` с причиной закрытия,
`Cancel` — сегодня на пустые `error_code`/`error_msg`; прежний код состояния
(`stalled` у `stuck`, `qbit_error` у `failed`) из записи пропадает безвозвратно.
Лог перехода закрытия его не дублирует: восстановить причину можно только по
более ранней строке перехода **в** `failed`/`stuck`, пока её держит ретенция
логов. Пустота кода на пути `Cancel` описана как сегодняшнее поведение, а не
закреплена нормативно: собственный непустой код у `Cancel` требованием не
запрещён.
Новый статус ни для одного из путей система вводить SHALL NOT —
переиспользуется существующее терминальное `cancelled` (сверка его не
переоценивает). Из `cancelled` пользователю остаётся доступной перепривязка
(relink), если он передумает; при этом `Undo` и `Delete` из `cancelled`
доступны SHALL NOT (их пол — `done`/`orphaned`/`target_missing`). Закрытие
разложенной загрузки поэтому оставляет её библиотечные ссылки без штатной
команды снятия, и **перепривязка их не снимает**: `Undo` и `Delete` работают
только с последним батчем раскладки, а повторное применение заводит новый.
Снять ссылки прежнего батча система средствами не даёт.
В интерфейсе «Закрыть» SHALL размещаться в отдельной «danger zone» (напр. внизу
страницы загрузки), обособленно от штатных действий, и SHALL быть отделена от
одиночного клика **дополнительным шагом**: раскрытием danger-зоны и явным
подтверждением там, где транспорт его поддерживает (диалог веб-UI при доступном
JS, второй шаг клавиатуры в Telegram). «Отменить»/«Отклонить» подтверждения
требовать SHALL NOT: команда отменяет работу, которая ещё идёт.
Отсюда следует **известное ограничение**: по `error_code` отличима «загрузка
закрыта стоп-краном» от «загрузка отменена активной», но не «пользователь
закрыл активную загрузку» от «пользователь отменил её» — оба пути на
не-терминальном состоянии дают один и тот же пустой код. Хуже того, маркер
сегодня кодирует не намерение, а поверхность: одно и то же состояние `stuck`
закрывается из веб-UI отменой (пустой код), а из Telegram стоп-краном
(`user_dismiss`). Цена — потеря различения в логах и диагностике; поведение и
данные не страдают. Восстановить различение можно, дав `Cancel` собственный
непустой код, — это требованием разрешено и оставлено открытым вопросом, а не
отвергнуто.
#### Scenario: Закрытие записи без цели не трогает раздачу
- **GIVEN** загрузка в `target_missing`: источник присутствует в qBittorrent,
целевых хардлинков нет (напр. её файлы разложены другой загрузкой)
- **WHEN** пользователь даёт команду «Закрыть»
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** раздача с файлами в qBittorrent не удаляется
- **AND** запись пропадает из активного списка
#### Scenario: Закрытие done оставляет библиотечные файлы на месте
- **GIVEN** загрузка в `done` с существующими библиотечными хардлинками
- **WHEN** пользователь даёт команду «Закрыть»
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** библиотечные хардлинки не удаляются
- **AND** раздача в qBittorrent не снимается
- **AND** команды `Undo` и `Delete` на записи становятся недоступны, а
«Привязать заново» остаётся доступной
- **AND** после перепривязки и повторного применения ссылки прежнего батча
остаются в библиотеке — их не снимает ни `Undo`, ни `Delete`
#### Scenario: Стоп-кран замещает прежнюю диагностику состояния
- **GIVEN** загрузка в `failed` с `error_code = "qbit_error"` и текстом ошибки
в `error_msg`
- **WHEN** пользователь даёт команду «Закрыть»
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** прежний код и текст ошибки в записи не сохраняются
- **AND** лог перехода закрытия их не дублирует — причина отказа восстановима
только по более раннему переходу в `failed`
#### Scenario: Закрытие не-терминальной загрузки из веб-UI идёт отменой
- **GIVEN** загрузка в не-терминальном состоянии (`downloading`, `recognizing`,
`review`, `deferred`, `stuck`), её страница открыта в веб-UI
- **WHEN** пользователь нажимает «Отменить» (на экране ревью — «Отклонить»)
- **THEN** запись переходит в `cancelled` и пропадает из активного списка
- **AND** `error_code` и `error_msg` перехода пусты (маркера `user_dismiss` на
этом пути нет)
- **AND** ни раздача в qBittorrent, ни хардлинки не трогаются
#### Scenario: Стоп-кран доступен там, где отмена уже отказывает
- **GIVEN** загрузка в терминальном состоянии, кроме `deleted` и `cancelled`
(`done`, `failed`, `reverted`, `target_missing`, `orphaned`)
- **WHEN** пользователь даёт команду «Закрыть» и подтверждает её
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** команда `Cancel` на том же состоянии отклоняется с конфликтом
#### Scenario: Один и тот же stuck закрывается разными путями с разных поверхностей
- **GIVEN** загрузка в `stuck`
- **WHEN** пользователь закрывает её из Telegram кнопкой «Закрыть»
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** та же загрузка, закрытая со страницы веб-UI кнопкой «Отменить», даёт
`cancelled` с пустым `error_code` — по коду перехода поверхности не
различить от намерения
#### Scenario: «Закрыть» недоступна для deleted
- **GIVEN** загрузка в `deleted`
- **WHEN** пользователь пытается вызвать «Закрыть»
- **THEN** команда недоступна, состояние остаётся `deleted`
#### Scenario: Повторное закрытие не подменяет причину прежнего
- **GIVEN** загрузка в `cancelled`, закрытая ранее отменой (пустой `error_code`)
- **WHEN** приходит команда «Закрыть»
- **THEN** команда проходит no-op'ом, записи состояния не происходит
- **AND** `error_code` остаётся прежним (пустым), а не подменяется на
`user_dismiss`
- **AND** команда «Отменить» на том же состоянии отклоняется с конфликтом
#### Scenario: Закрытую запись можно привязать заново
- **GIVEN** запись, закрытая командой «Закрыть» в `cancelled`
- **WHEN** пользователь даёт команду «Привязать заново»
- **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink
из `cancelled`)
@@ -0,0 +1,64 @@
## 1. Спека
- [x] 1.1 Влить дельту в `openspec/specs/state-reconciliation/spec.md`
(требование «Ручное закрытие загрузки (стоп-кран)» целиком) — делает
`opsx:archive`/`opsx:sync`, руками спека не правится.
- [x] 1.2 Прогнать `openspec validate --strict cancel-dismiss-split-wording`.
## 2. Код
- [x] 2.1 Правок нет и быть не должно: `internal/httpapi/download.go`,
`web/templates/partials/download_main.html`, `internal/tgbot/render.go`,
`internal/worker/worker.go` — только чтение. Проверка — пустой
`git diff --stat` под `internal/` и `web/`.
## 3. Верификация
- [x] 3.1 `task gate` зелёный.
- [x] 3.2 Ревью изменения (второй чекпоинт) проведено, триаж сохранён в
`openspec/changes/cancel-dismiss-split-wording/review/`.
## Рубрика ревью предложения (проход `review-rubric`, чекпоинт 1)
Свойства, по которым судится требование о пользовательской команде, переводящей
сущность в терминальное состояние. Порождены до чтения предмета; здесь стоят
приёмочными критериями наравне с A1–A3.
- [x] R1. Отрицания побочных эффектов сверены с carve-out'ами инвариантов: у
каждого «SHALL NOT трогать X» есть либо сценарий, либо ссылка на требование,
где X всё-таки трогается.
- [x] R2. Пол доступности задан перечислением состояний, названо, что бывает при
вызове из недопустимого (конфликт или no-op).
- [x] R3. Идемпотентность повтора определена **по каждому пути отдельно**, без
обобщающего «закрытие».
- [x] R4. Различимость инициатора зафиксирована литералом; где различение
теряется — записано ограничением, а не умолчано.
- [x] R5. Судьба прежней диагностики названа для обоих путей.
- [x] R6. Обязательства поверхностей фальсифицируемы: по тексту строится таблица
«состояние × поверхность»; круговых формулировок нет.
- [x] R7. Подтверждение опасного нормировано в теле требования, а не только в
сценарии одной поверхности — **по фактическому гейту**: обособление плюс
дополнительный шаг там, где транспорт его поддерживает (чекпоинт 2, D11).
- [x] R8. Названо, какие штатные команды из полученного терминального состояния
отпадают.
- [x] R9. Конкурентность разрешена правилом «кто первый» (единый сериализующий
узел).
- [ ] R10. Допустимость команды переоценивается на момент исполнения, а не по
снимку рендера. **Не закрыто этим изменением** — отдано урожаем: свойство
общее для всех команд проекта, дом ему не в этом требовании.
- [x] R11. Терминальность имеет описанный выход (relink) и согласована со
сверкой.
- [x] R12. Спека фиксирует наблюдаемое, а не внутреннее: косметическая правка UI
не делает её ложной.
## Критерии приёмки (из постановки `docs/tasks/items/dismiss-marker-lost.md`)
- [x] A1. Требование спеки различает `Cancel` и `Dismiss` по состояниям и
называет `error_code` для каждого пути закрытия.
**Оракул:** `openspec validate --strict`.
- [x] A2. В спеке есть сценарий «пользователь закрывает не-терминальную
загрузку из веб-UI» с исходом `cancelled` и названным `error_code`.
**Оракул:** тот же прогон.
- [x] A3. Гейт `Dismissable` в коде и danger-zone шаблона остаются как есть.
**Оракул:** `git diff --stat` в отчёте ревью — файлов под `internal/` и
`web/` нет.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-06

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