Compare commits
35
Commits
a4f5faae47
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5992d75b57
|
||
|
|
f89911447d
|
||
|
|
b939192348
|
||
|
|
288be8ec34
|
||
|
|
a7c1efd8eb
|
||
|
|
f6f07e520b
|
||
|
|
a5d873b62d
|
||
|
|
969926fae3
|
||
|
|
1479d10d4c
|
||
|
|
b9f0929d0c
|
||
|
|
1710e5a9d5
|
||
|
|
9aecf757e0
|
||
|
|
bb278e8744
|
||
|
|
6001f21a60
|
||
|
|
69853a96c9
|
||
|
|
c5d62d76ee
|
||
|
|
9a624d4e13
|
||
|
|
397f8aa2c3
|
||
|
|
1d375f55ba
|
||
|
|
fdbc781197
|
||
|
|
0c83385098
|
||
|
|
a47a767691
|
||
|
|
b879c049ea
|
||
|
|
b450ab1fd5 | ||
|
|
d081ef1d30 | ||
|
|
52615e4e49
|
||
|
|
5c79fdfffe
|
||
|
|
0b02a8c224
|
||
|
|
01e64d60de
|
||
|
|
5a8c439899
|
||
|
|
30ee598547
|
||
|
|
f42db0a275
|
||
|
|
d2d386945e
|
||
|
|
66d39297c5
|
||
|
|
905a1ee4d3
|
@@ -2,8 +2,9 @@
|
||||
"enabledPlugins": {
|
||||
"frontend-design@claude-plugins-official": true,
|
||||
"av-dev-git@av-dev-skills": true,
|
||||
"av-dev-pm@av-dev-skills": true,
|
||||
"av-dev-pipeline@av-dev-skills": true
|
||||
"av-dev-docs@av-dev-skills": true,
|
||||
"av-dev-tasks@av-dev-skills": true,
|
||||
"av-dev-code@av-dev-skills": true
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"av-dev-skills": {
|
||||
|
||||
@@ -22,7 +22,9 @@ Driven Development** через OpenSpec — см. раздел ниже.
|
||||
Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module path —
|
||||
`git.vakhrushev.me/av/jellybit`. SQLite через `modernc.org/sqlite` + `sqlx`,
|
||||
миграции `goose`, HTTP — `chi` + `html/template` + htmx, конфиг —
|
||||
`pelletier/go-toml/v2`, логи — `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` допустимы только чтение
|
||||
и `link(2)`; никаких `unlink`, `rename`, записи. Нарушение уничтожает
|
||||
невосстановимые данные пользователя. **Необратимо. `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` отклоняется целиком, если у цели не
|
||||
осталось других жёстких ссылок (`nlink <= 1`) или исходного файла уже нет.
|
||||
Частичный откат тоже стёр бы часть данных. **Необратимо. `critical`.**
|
||||
@@ -47,14 +65,24 @@ Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module
|
||||
qBittorrent, ключи LLM и метабаз, токен Telegram, API-ключ Jellyfin.
|
||||
Утёкший в лог секрет отзывается вручную. **`major`.**
|
||||
- **Авто-раскладка только при подтверждённом матче в метабазе** — самооценка
|
||||
LLM гейтом не является
|
||||
([ADR](docs/adr/ADR-2026-06-13-auto-link-requires-db-match.md)). Обратимо
|
||||
LLM **единственным** гейтом не является и матч не заменяет: порог
|
||||
`[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`.**
|
||||
- **Не более одной активной загрузки на infohash** — проверка отсутствия другой
|
||||
активной загрузки и вставка идут одной write-транзакцией (`_txlock=immediate`,
|
||||
guarded-методы `store`); обход даёт две задачи, претендующие на одну раздачу и
|
||||
один целевой путь. Обратимо (лишняя закрывается), но состояние расходится.
|
||||
**`major`.** Поведение — [ingest](openspec/specs/ingest/spec.md).
|
||||
- **Переходы состояний — только через `worker` под per-download блокировкой**,
|
||||
и только легальные по декларативному графу. Обход даёт гонку двух
|
||||
транспортов. **`major`.**
|
||||
- **Время — только `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`, тесты,
|
||||
флаки-прогон (второй прогон разошёлся с первым), `-race`, накат миграций с
|
||||
нуля, `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`: находка тут — состояние
|
||||
зависимостей, а не диффа. Разбирает агент ревью по трассам вызовов.
|
||||
- `-race` без gcc уходит в `SKIP` с явным «гонки НЕ проверены» — тогда их
|
||||
проверяет проход `ops` рассуждением, и это идёт в границы покрытия.
|
||||
проверяет рассуждением тема `operations` ([docs/review.md](docs/review.md)
|
||||
→ «Вопросы по темам»), и это идёт в границы покрытия.
|
||||
- Ничего не гоняется против **живого** qBittorrent, LLM и метабаз:
|
||||
интеграционные тесты за 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`), в неё вливает батч, от неё ветвятся задачи.
|
||||
- **Необратимое** (спрашивается у человека всегда): всё, что пишет в
|
||||
`paths.downloads` или удаляет оттуда; удаление раздачи из qBittorrent вместе
|
||||
с файлами (`Delete`); снятие последней копии данных; правка уже применённой
|
||||
миграции; `git push --force`; удаление или перезапись файла в библиотеке
|
||||
Jellyfin, которого мы не создавали.
|
||||
- **Общий станок** — покрасневший `task gate` на `master` врывается в
|
||||
замороженный спринт: пока он красный, ни одна задача не считается сделанной.
|
||||
- **Ориентир по размеру спринта:** 5–8 задач. Ориентир, а не закон.
|
||||
с файлами (`Delete`) — кроме уборки собственного, только что добавленного
|
||||
торрента, когда закрытие любым путём увело задачу из `catched` (см.
|
||||
исключения инварианта выше); снятие последней копии
|
||||
данных; правка уже применённой миграции; `git push --force`; удаление или
|
||||
перезапись файла в библиотеке Jellyfin, которого мы не создавали.
|
||||
- **Что считается сломанным:** покрасневший `task gate` на `master`. Пока он
|
||||
красный, ни одна задача не считается сделанной, и чинится он раньше любой
|
||||
другой работы: гейт один на все задачи.
|
||||
- **Приоритет — это порядок строк в [tasks/BACKLOG.md](tasks/BACKLOG.md).**
|
||||
Первая строка секции — то, что делают следующим. Порядок назначает человек на
|
||||
груминге (`av-dev-tasks:groom`), машина его не выводит.
|
||||
- **Ориентир по размеру порции разбора на груминге:** 5–8 задач. Ориентир, а не
|
||||
закон.
|
||||
- **Что такое «сделана»:** пайплайн задачи пройден целиком (спека → код → оба
|
||||
чекпоинта ревью → archive) и критерии приёмки проверены поимённо.
|
||||
|
||||
@@ -132,30 +173,30 @@ Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module
|
||||
(CLI `openspec`, v1.x). Сначала спецификация — потом код.
|
||||
|
||||
- `openspec/specs/<capability>/spec.md` — **нормативный дом поведения**: что
|
||||
система делает сейчас. Capability — это поведение или домен (`ingest`,
|
||||
`recognition`, `file-layout`, `review`, `notifications`), а не пакет кода.
|
||||
система делает сейчас. Capability — это поведение или домен системы, а не
|
||||
пакет кода.
|
||||
- `openspec/changes/<id>/` — предлагаемое изменение: `proposal.md`, `design.md`
|
||||
(для нетривиальных), дельта-спеки (`ADDED`/`MODIFIED`/`REMOVED Requirements`),
|
||||
`tasks.md`. После реализации change архивируется в
|
||||
`openspec/changes/archive/`, дельты вливаются в `openspec/specs/`.
|
||||
- `openspec/config.yaml` — только нужды генерации артефактов: язык, правила
|
||||
именования capability, придирки валидатора.
|
||||
именования capability, придирки валидатора — плюс адреса документов канона.
|
||||
Пересказа этих документов там нет: второй дом факта расходится молча.
|
||||
|
||||
Поток работы — через слэш-команды `opsx:*`: `opsx:explore` (продумать),
|
||||
`opsx:propose` (завести change), `opsx:apply` (реализовать tasks),
|
||||
`opsx:sync`/`opsx:archive` (влить и архивировать).
|
||||
|
||||
Правила спек:
|
||||
Правила спек — язык, именование capability и придирки валидатора — живут в
|
||||
[openspec/config.yaml](openspec/config.yaml) (`context` и `rules`), оттуда их
|
||||
читает порождение артефактов; здесь не дублируются. Перед коммитом change —
|
||||
`openspec validate --strict`.
|
||||
|
||||
- Каждое `### Requirement` ОБЯЗАНО содержать литерал `SHALL` или `MUST` —
|
||||
иначе `openspec validate` падает.
|
||||
- Структурные заголовки и ключевые слова — английские (`### Requirement:`,
|
||||
`#### Scenario:`, `GIVEN/WHEN/THEN`, RFC 2119), остальной текст — русский.
|
||||
- `openspec validate --strict` перед коммитом change.
|
||||
|
||||
Ревью — два чекпоинта: профиль `design` на предложении (после design/specs, ДО
|
||||
кода) и ревью изменения после apply, до archive. Настройка конвейера под проект
|
||||
и журнал дефектов — [docs/review.md](docs/review.md).
|
||||
Ревью — два чекпоинта: ревью дизайна на предложении (после design/specs, ДО
|
||||
кода) и ревью изменения после apply, до archive. Состав обоих выбирается по
|
||||
метке задачи (`small` / `medium` / `large`), которую разметка ставит один раз
|
||||
после propose. Настройка конвейера под проект и журнал дефектов —
|
||||
[docs/review.md](docs/review.md).
|
||||
|
||||
## Документация
|
||||
|
||||
@@ -172,12 +213,12 @@ Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module
|
||||
- [docs/research/](docs/research/README.md) — наблюдения за чужими форматами.
|
||||
- [docs/adr/](docs/adr/README.md) — журнал решений, неизменяемый.
|
||||
- [docs/review.md](docs/review.md) — настройка конвейера ревью и журнал дефектов.
|
||||
- [docs/tasks/](docs/tasks/BACKLOG.md) — задачи и цели: одна запись = один файл
|
||||
в `items/` + строка в индексе. Ведётся скиллом `av-dev-pm:tasks`, ритуал
|
||||
спринта — `av-dev-pm:session`.
|
||||
- [tasks/](tasks/BACKLOG.md) — задачи и цели: одна запись = один файл
|
||||
в `items/` + строка в индексе, порядок строк = приоритет. Ведётся скиллом
|
||||
`av-dev-tasks:tasks`, разбор беклога — `av-dev-tasks:groom`.
|
||||
|
||||
**Tududi** (проект `jellybit`, project_id 14) — только инбокс сырых идей. Идея
|
||||
становится задачей, когда её оформляют файлом в `docs/tasks/items/`.
|
||||
становится задачей, когда её оформляют файлом в `tasks/items/`.
|
||||
|
||||
## Конвенции кода
|
||||
|
||||
|
||||
@@ -35,14 +35,10 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
|
||||
|
||||
## Статус
|
||||
|
||||
Рабочий прототип с полным сквозным путём: приём magnet → загрузка в
|
||||
qBittorrent → распознавание (LLM + опционально базы метаданных
|
||||
TMDB/TVDB/TVMaze) → раскладка в библиотеку хардлинками, автоматически при
|
||||
уверенном результате либо через подтверждение человеком. Транспорты приёма:
|
||||
REST API, веб-UI, Telegram-бот и CLI (`jellybit add`).
|
||||
|
||||
Из источников поддержаны magnet и `.torrent`-файл; фетч `.torrent` по обычной
|
||||
ссылке — в планах. Что дальше — [docs/tasks/PLAN.md](docs/tasks/PLAN.md).
|
||||
Рабочий прототип: сквозной путь приём → загрузка → распознавание → раскладка
|
||||
работает целиком, автоматически при уверенном результате либо через
|
||||
подтверждение человеком. Что уже умеет и что дальше —
|
||||
[tasks/ROADMAP.md](tasks/ROADMAP.md).
|
||||
|
||||
## Документация
|
||||
|
||||
@@ -64,7 +60,7 @@ REST API, веб-UI, Telegram-бот и CLI (`jellybit add`).
|
||||
[веб-UI](docs/conventions/web-ui.md).
|
||||
- [docs/adr/](docs/adr/README.md) — журнал решений (почему так), неизменяемый.
|
||||
- [docs/research/](docs/research/README.md) — наблюдения за чужими форматами.
|
||||
- [docs/tasks/](docs/tasks/BACKLOG.md) — задачи и цели.
|
||||
- [tasks/](tasks/BACKLOG.md) — задачи и цели.
|
||||
|
||||
Раскладка документации задана каноном av-dev и проверяется шагом `canon` в
|
||||
`task gate`.
|
||||
@@ -73,7 +69,8 @@ REST API, веб-UI, Telegram-бот и CLI (`jellybit add`).
|
||||
|
||||
Go (один статический бинарь), SQLite (`modernc.org/sqlite` + `sqlx`,
|
||||
миграции `goose`), HTTP — `chi` + `html/template` + htmx, конфигурация —
|
||||
TOML, логи — структурированный JSON (`slog`). Подробнее — в
|
||||
TOML, логи — структурированный JSON (`slog`). Полный перечень с версиями —
|
||||
[CLAUDE.md](CLAUDE.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`). Образ
|
||||
собирается целиком **локально** на control-хосте (`task image`) и едет на
|
||||
сервер через `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.
|
||||
Рассчитан на домашний медиа-сервер. Артефакты репозитория — статический бинарь
|
||||
(`task build`) и `Dockerfile`; образ собирается целиком локально на
|
||||
control-хосте (`task image`) и едет на сервер через `docker save`/`load`.
|
||||
Конкретная деплой-обвязка (плейбук, секреты) держится в отдельном приватном
|
||||
репозитории и в комплект не входит.
|
||||
|
||||
Параметры запуска — сеть, пользователь, монтирования, healthcheck, — разделение
|
||||
ответственности с umbar и единая песочница `/srv/media`:
|
||||
[docs/architecture.md](docs/architecture.md) → «Деплой».
|
||||
|
||||
@@ -275,9 +275,10 @@ func metadataProviders(cfg *config.Config, logger *slog.Logger) ([]metadata.Prov
|
||||
// пропускаем (сервис стартует), а не падаем.
|
||||
if cfg.Metadata.TVDB.Enabled && cfg.Metadata.TVDB.APIKey != "" {
|
||||
p, err := metadata.NewTVDB(metadata.TVDBConfig{
|
||||
APIKey: cfg.Metadata.TVDB.APIKey,
|
||||
Proxy: cfg.Metadata.TVDB.Proxy,
|
||||
Timeout: cfg.Metadata.TVDB.Timeout.Std(),
|
||||
APIKey: cfg.Metadata.TVDB.APIKey,
|
||||
Proxy: cfg.Metadata.TVDB.Proxy,
|
||||
Timeout: cfg.Metadata.TVDB.Timeout.Std(),
|
||||
Language: cfg.ContentLanguage(),
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tvdb provider: %w", err)
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ timeout = "10s" # таймаут запроса к TMDB; Go
|
||||
enabled = false # включить провайдера TVDB
|
||||
api_key = "" # секрет: ключ TVDB; обязателен, если enabled (заполняет деплой)
|
||||
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
||||
timeout = "10s" # таймаут запроса к TVDB; Go-duration (s/m/h)
|
||||
timeout = "10s" # таймаут запроса к TVDB; Go-duration (s/m/h). Локаль названий задаёт [general].language: в запрос поиска не уходит, применяется при разборе ответа
|
||||
|
||||
[metadata.tvmaze]
|
||||
enabled = false # включить провайдера TVMaze; без ключа, только сериалы (тег [tvdbid-…] из externals)
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"canon": 2,
|
||||
"canon": 12,
|
||||
"migrations": "internal/store/migrations"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Авто-раскладка только при подтверждённом матче в метабазе
|
||||
|
||||
- **Дата:** 2026-06-13
|
||||
- **Источник:** архивного `design.md` нет — решение принято до перехода
|
||||
проекта на OpenSpec (первый архивный change — `2026-06-28`); первичный
|
||||
материал — история git
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Docker как единица деплоя, образ собирается на сервере
|
||||
|
||||
- **Дата:** 2026-06-13
|
||||
- **Источник:** архивного `design.md` нет — решение принято до перехода
|
||||
проекта на OpenSpec (первый архивный change — `2026-06-28`); первичный
|
||||
материал — история git
|
||||
- **Статус:** заменено на ADR-2026-07-24-local-image-build
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Go и доставка одним бинарём
|
||||
|
||||
- **Дата:** 2026-06-13
|
||||
- **Источник:** архивного `design.md` нет — решение принято до перехода
|
||||
проекта на OpenSpec (первый архивный change — `2026-06-28`); первичный
|
||||
материал — история git
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Хардлинки вместо копирования и симлинков
|
||||
|
||||
- **Дата:** 2026-06-13
|
||||
- **Источник:** архивного `design.md` нет — решение принято до перехода
|
||||
проекта на OpenSpec (первый архивный change — `2026-06-28`); первичный
|
||||
материал — история git
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Конвейер ревью: гейт, generative-проходы и обязательный триаж
|
||||
|
||||
- **Дата:** 2026-07-23
|
||||
- **Источник:** архивного `design.md` нет — решение процессное, change'ем jellybit
|
||||
не велось
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Образ собирается локально и едет на сервер через docker save/load
|
||||
|
||||
- **Дата:** 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
@@ -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-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) | — |
|
||||
|
||||
+41
-16
@@ -48,21 +48,27 @@
|
||||
| `archrules` | собственный анализатор архитектурных правил (часть гейта) | — |
|
||||
|
||||
Транспорты приёма — HTTP API, веб-UI, Telegram и CLI (`jellybit add`) — ведут в
|
||||
один `ingest`; действия пользователя (apply / refine / reject / defer / undo /
|
||||
retry / delete / dismiss) идут командами к `worker`.
|
||||
один `ingest`; действия пользователя идут командами к `worker`. Перечень команд
|
||||
и их эффекты — нормативно в [review](../openspec/specs/review/spec.md), пути
|
||||
закрытия и удаления — в
|
||||
[state-reconciliation](../openspec/specs/state-reconciliation/spec.md).
|
||||
|
||||
## Внешние границы и форматы
|
||||
|
||||
- **qBittorrent WebUI API** — единственный способ качать: источник (magnet, URL,
|
||||
`.torrent`) **отдаём ему**, сами по пользовательскому URL не ходим (SSRF
|
||||
исключён). Пути берём из API (`save_path` + относительные имена из
|
||||
`/torrents/files`), не из константы.
|
||||
- **LLM** — OpenAI-совместимый Chat Completions (`[llm].type = "openai-compat"`),
|
||||
структурированный вывод через `response_format: json_object`; валидация ответа
|
||||
своя, в Go.
|
||||
- **qBittorrent WebUI API** — единственный способ качать: источник **отдаём
|
||||
ему**, сами по пользовательскому URL не ходим (SSRF исключён). Какие виды
|
||||
источника принимаются и как разбираются —
|
||||
[ingest](../openspec/specs/ingest/spec.md); способ добавления по
|
||||
`source_type` — [download-tracking](../openspec/specs/download-tracking/spec.md);
|
||||
откуда берутся пути файлов —
|
||||
[file-layout](../openspec/specs/file-layout/spec.md).
|
||||
- **LLM** — OpenAI-совместимый Chat Completions за интерфейсом (`[llm].type`);
|
||||
контракт вызова, формат вывода и разбор ответа —
|
||||
[recognition](../openspec/specs/recognition/spec.md).
|
||||
- **Метабазы** — TMDB, TVDB, TVMaze (последняя без ключа, только сериалы).
|
||||
- **Jellyfin** — один вызов `POST /Library/Refresh`, авторизация заголовком
|
||||
`X-Emby-Token`.
|
||||
- **Jellyfin** — HTTP-триггер пересканирования медиатеки, опционален; когда он
|
||||
дёргается и по каким переходам —
|
||||
[file-layout](../openspec/specs/file-layout/spec.md).
|
||||
- **Telegram Bot API** — приём сообщений и исходящие карточки/пинги.
|
||||
- **Сообщение торрент-бота** — чужой текстовый формат, разбирается парсером
|
||||
`tgbot`; наблюдения по формату — в
|
||||
@@ -90,8 +96,9 @@ retry / delete / dismiss) идут командами к `worker`.
|
||||
задаче, застрявшей в промежуточном состоянии; логи в stdout контейнера.
|
||||
Автоматического алертинга нет, метрик нет — только уведомления в Telegram о
|
||||
падении загрузки и о рассинхроне.
|
||||
- **Характер потока:** непрерывный фон (тик поллинга qBittorrent, по умолчанию
|
||||
5 с, и периодическая сверка) плюс редкие события по запросу человека. Объём —
|
||||
- **Характер потока:** непрерывный фон (тик поллинга qBittorrent — период в
|
||||
[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/naming` — одна логика для превью в UI и для реального применения |
|
||||
| Чистка человекочитаемых значений | три санитайзера с разным предметом, сводить их в один нельзя: `recognize.SanitizeTitle` — значение (недоверенный вход: LLM и метабазы), `layout.sanitizeComponent` — компонент пути под требования ФС, `naming.sanitize` — отображаемый ярлык. Значение метабазы чистится **на каждой** точке входа в план: сборка матча, копия кандидата для ревью, набор закреплённых значений источника и его чтение — [ADR-2026-08-10-sanitize-at-every-entry](adr/ADR-2026-08-10-sanitize-at-every-entry.md) |
|
||||
| Разбор источника | `internal/magnet` и `internal/torrent`; инфохэш извлекается только здесь |
|
||||
| Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI |
|
||||
| Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом |
|
||||
| Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки |
|
||||
| Построение и проверка целевого пути | `layout.BuildLinks` — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью |
|
||||
| Причина, по которой человек не видит плана | считается **на показе** (`worker.ReviewData.PreviewError`) и предпочитается записанной в состоянии: записанной может не быть вовсе, а после смены источника она уже про другой план — [ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md) |
|
||||
| Условие допуска полного удаления | `store.State.CanDelete()` — «из этого состояния удаление с файлами разрешено»; своего перечня состояний не заводит ни один транспорт (веб-UI, Telegram, страница группового удаления), а проверку в ядре предикат не заменяет: допуск держится без транспорта |
|
||||
| Условие самообновления веб-UI | `store.State.IsObservable()` — «состояние ещё может измениться без человека»; транспорт своего перечня состояний не заводит, а поверхность (карточка списка, страница загрузки) держит **ровно один** поллер на обновляемый корень — [ADR-2026-08-10-observability-is-not-terminality](adr/ADR-2026-08-10-observability-is-not-terminality.md), правило разметки — [conventions/web-ui.md](conventions/web-ui.md) |
|
||||
| Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) |
|
||||
| Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) |
|
||||
| Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям |
|
||||
@@ -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 | лимита на размер ответа нет — единственный недоверенный канал без предела; задачей пока не заведено | — |
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел
|
||||
«Конвенции кода».
|
||||
|
||||
> Каркас. Загрузчик `internal/config/config.go` уже грузит TOML; валидация
|
||||
> на старте — в работе (`TODO`), обкатывается на следующем шаге.
|
||||
|
||||
## Принципы
|
||||
|
||||
- **Конфигурация — только TOML.** Env-переменные для конфига **не
|
||||
@@ -40,17 +37,24 @@
|
||||
|
||||
```toml
|
||||
[worker]
|
||||
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
|
||||
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration
|
||||
source_missing_threshold = 3 # тиков сверки без раздачи, чтобы счесть источник удалённым
|
||||
poll_interval = "<duration>" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
|
||||
magnet_timeout = "<duration>" # ждать метаданные magnet не дольше; Go-duration
|
||||
source_missing_threshold = <N> # тиков поллинга без раздачи, чтобы счесть источник удалённым
|
||||
|
||||
[recognition]
|
||||
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
|
||||
auto_confidence_threshold = <0.0–1.0> # порог авто-раскладки без ревью; доля
|
||||
|
||||
[llm]
|
||||
max_retries = 3 # попыток получить валидный ответ LLM; целое ≥ 0
|
||||
max_retries = <N> # попыток получить валидный ответ LLM; целое ≥ 0
|
||||
```
|
||||
|
||||
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
|
||||
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
|
||||
начинают врать молча при смене умолчания. Действующие умолчания и их
|
||||
смысл живут одним домом — таблица «Настройки с числовым значением» в
|
||||
[../database.md](../database.md); `config.example.toml` — источник истины по
|
||||
составу полей.
|
||||
|
||||
Секретные поля оставляем пустыми — значение приходит из деплоя (см.
|
||||
«Секреты»).
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Как мы устраиваем таблицы и ключи в SQLite. Актуальная схема —
|
||||
[../database.md](../database.md); обоснование выбора ULID —
|
||||
`openspec/changes/ulid-identity/design.md` (после архивации — в истории git).
|
||||
[архивный design.md change'а `ulid-identity`](../../openspec/changes/archive/2026-07-02-ulid-identity/design.md).
|
||||
|
||||
**Механизировано:** `AUTOINCREMENT` и `DEFAULT (datetime('now'))` в новых
|
||||
миграциях (`internal/archrules`), время мимо `store.Now()` (`forbidigo`).
|
||||
|
||||
@@ -66,7 +66,15 @@ jellybit — **приложение, а не библиотека**: внешн
|
||||
- **+ корреляционный ключ** для владельца — `download_id` (если операция
|
||||
к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах.
|
||||
Пример: «При обработке загрузки произошла ошибка, download_id=12345», а
|
||||
не «произошла ошибка» и не сырой текст;
|
||||
не «произошла ошибка» и не сырой текст.
|
||||
**Ключ есть не у всякого транспорта, и это называется вслух.** `request_id`
|
||||
— понятие HTTP-границы (chi `RequestID`); у Telegram и CLI его нет. Если
|
||||
операция ещё не завела загрузку (отказ приёма), у такого транспорта ключа
|
||||
нет вовсе — тогда сообщение остаётся без якоря, а диагностика ищется по
|
||||
записи доменной границы (`capability`, `infohash`). Заводить транспорту
|
||||
собственный идентификатор запроса ради ключа — решение уровня спеки, а не
|
||||
умолчание: второй канал корреляции рядом с существующим дороже, чем
|
||||
отсутствие ключа;
|
||||
- **маппинг доменной ошибки → статус/сообщение** (в jellybit —
|
||||
`httpapi.classifyErr`, единая точка для REST и веб-UI):
|
||||
|
||||
@@ -74,9 +82,14 @@ jellybit — **приложение, а не библиотека**: внешн
|
||||
|---|---|---|
|
||||
| `store.ErrNotFound` | 404 | «не найдено» |
|
||||
| `magnet.ErrNotMagnet` / `torrent.ErrNotTorrent` | 400 | «некорректный источник» |
|
||||
| `ingest.ErrTorrentTooLarge` (файл больше лимита) | 400 | «файл .torrent слишком большой» |
|
||||
| `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» |
|
||||
| `errManualSource` (ручной ввод источника, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
|
||||
| `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
|
||||
| `errBatchEmpty` / `errBatchTooLarge` / `errBatchBadID` (разбор пачки группового удаления, локальные sentinel'ы `httpapi`) | 400 | текст самой ошибки |
|
||||
| `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» |
|
||||
| `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» |
|
||||
| `layout.ErrNameTooLong` (целевое имя не помещается, ушло в review) | 409 | «целевое имя слишком длинное…» |
|
||||
| `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» |
|
||||
| прочее | 500 | «внутренняя ошибка» |
|
||||
|
||||
@@ -97,7 +110,7 @@ jellybit — **приложение, а не библиотека**: внешн
|
||||
в review/failed: коллизия, рассинхрон, сбой ФС) и `reasons` распознавания,
|
||||
сохранённые в БД и показываемые на экране ревью и в Telegram-карточке. Это
|
||||
**операторская поверхность владельца**: сервис однопользовательский в
|
||||
доверенной LAN (см. [architecture.md](../architecture.md)), эти поля —
|
||||
доверенном контуре (см. [security.md](../security.md) → «Периметр»), эти поля —
|
||||
диагностический контекст для того, кто разбирает задачу. Здесь сырой текст
|
||||
ошибки (пути, фрагмент ответа LLM/qBittorrent) **допустим и полезен** — но:
|
||||
- **секреты запрещены** абсолютно (токены/ключи/пароли/`Authorization`) — так
|
||||
@@ -105,7 +118,13 @@ jellybit — **приложение, а не библиотека**: внешн
|
||||
error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок
|
||||
транспорта, несущих URL с секретом);
|
||||
- это **не** канал для транзиентных отказов команд — те остаются нейтральными
|
||||
(см. выше).
|
||||
(см. выше);
|
||||
- **внешнее значение в тексте усекается на границе, а его размер называется
|
||||
числом.** `error_msg` уезжает в баннер ревью, в панель действий и в карточку
|
||||
Telegram; имя файла на 400 байт занимает там экран целиком и оседает в БД
|
||||
навсегда. Усечение — серединой и по рунам (`layout.shorten`,
|
||||
`naming.truncate`, `tgbot.shorten`), точная величина остаётся числом рядом:
|
||||
без неё человек не поймёт, насколько сокращать.
|
||||
|
||||
## panic
|
||||
|
||||
|
||||
@@ -74,9 +74,10 @@ OpenSpec-спеках (`### Requirement` с `SHALL`).
|
||||
- Поле — `time` (ключ по умолчанию `slog`).
|
||||
- UTC, RFC 3339 с долями секунды, суффикс `Z`:
|
||||
`2026-06-28T11:23:45.123456Z`.
|
||||
- Логи — **в UTC** (это явный TZ, не нарушает инвариант проекта): даёт
|
||||
однозначный порядок событий и лексикографическую сортировку. Бизнес-логика
|
||||
по-прежнему работает в `Europe/Moscow` — UTC только в логах.
|
||||
- Логи — **в UTC**, как и хранение в БД: это даёт однозначный порядок событий и
|
||||
лексикографическую сортировку. Часовой пояс есть только у **отображения** в
|
||||
веб-UI (`[general].timezone`, дефолт `UTC`) — см.
|
||||
[database.md](../database.md); бизнес-логика в локальной зоне не работает.
|
||||
|
||||
## Поля: словарь имён
|
||||
|
||||
@@ -203,6 +204,11 @@ Go-ошибки логируем как атрибут, не как текст
|
||||
|
||||
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
|
||||
`http.status_code`, `duration_ms`, `transport` (`http`/`web`/`telegram`).
|
||||
- **Поле, которое уже даёт scoped-логгер, руками не доклеиваем.** Команда,
|
||||
положившая scoped-логгер в `ctx`, не передаёт `download_id` ещё и аргументом
|
||||
записи: в JSON получается дублирующийся ключ, и строгий потребитель молча
|
||||
оставит одно из значений. Правило следует из «логгер несёт ключи сам» и
|
||||
проверяется чтением — линтером не выражается.
|
||||
- Для корреляции HTTP-запроса допустим `request_id` (напр. chi `RequestID`) —
|
||||
это отдельный слой от корреляции загрузки по `download_id` и не противоречит
|
||||
отказу от `trace_id`. Если запрос порождает загрузку — связь даёт
|
||||
|
||||
+35
-14
@@ -103,27 +103,48 @@ htmx по умолчанию **не свопит DOM на ответы 4xx/5xx**
|
||||
## Живой поллинг
|
||||
|
||||
Паттерн живого обновления: фрагмент-эндпоинт под `/fragments/...` + в разметке
|
||||
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"` (эталон —
|
||||
`progress`/`seeding`, `handleFragProgress`/`handleFragSeeding`):
|
||||
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`. Эталон — карточка
|
||||
списка (`card`, `handleFragCard`):
|
||||
|
||||
```html
|
||||
{{define "progress"}}<div id="dl-live-{{.ID}}"
|
||||
{{if .Active}} hx-get="/fragments/downloads/{{.ID}}/progress"
|
||||
hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
|
||||
{{define "card"}}<article class="card" id="card-{{.ID}}"
|
||||
{{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card"
|
||||
hx-trigger="every {{.PollEvery}}" hx-swap="outerHTML"{{end}}>
|
||||
...
|
||||
</div>{{end}}
|
||||
</article>{{end}}
|
||||
```
|
||||
|
||||
- **Поллер самозавершается.** Когда состояние выходит из «живого» (`Active`
|
||||
ложно, торрент не сидирует), фрагмент возвращается **без `hx-*`** — htmx
|
||||
больше не опрашивает. Условие «живости» ведёт store-состояние (`downloading`
|
||||
для прогресса), а не qBittorrent.
|
||||
- **Один поллер на обновляемый корень.** Опрашивает себя корень поверхности
|
||||
(карточка списка, главная область страницы), а вложенные живые регионы —
|
||||
прогресс качания, секция раздачи — своего `hx-get` **не несут**: своп корня
|
||||
уносит их вместе с таймером, и два опроса подменяли бы разметку друг друга.
|
||||
Живые цифры приезжают вместе с корнем.
|
||||
- **Поллер самозавершается.** Опрос ведётся, пока предмет может измениться без
|
||||
участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx
|
||||
больше не опрашивает. Условие определяется store-состоянием
|
||||
(`State.IsObservable()`), а не qBittorrent.
|
||||
- **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает
|
||||
`200` и фрагментом с объяснением **без `hx-*`**: htmx не свопит `4xx/5xx`,
|
||||
поэтому статус ошибки оставил бы поверхность навсегда прежней, а опрос —
|
||||
бесконечным. Фрагмент отказа обязан нести корневой `id` того узла, который он
|
||||
собой заменяет (см. инвариант выше), иначе `hx-swap` подменит не тот узел.
|
||||
- **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный ретрай;
|
||||
`ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)).
|
||||
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
|
||||
поллером и htmx `process`-инициализирует новый — двойного опроса нет **при
|
||||
условии совпадения корневого `id`** (см. инвариант выше).
|
||||
- Данные тика — из in-memory снимка воркера (`LiveStatus.Live(infohash)`), без
|
||||
БД/сети на каждый тик; узкий контракт `LiveStatus` не зависит от способа
|
||||
доставки (поллинг сейчас, путь к SSE оставлен изолированным).
|
||||
условии совпадения корневого `id`** (см. инвариант выше). Эфемерное состояние
|
||||
разметки своп не переживает: то, что должно пережить тик (раскрытый
|
||||
`<details>`), помечается `hx-preserve`.
|
||||
- **Частота — по цене тика, и она названа числом в
|
||||
[database.md](../database.md).** Поверхность с живыми цифрами качания
|
||||
обновляется чаще (`pollFast`, вровень с частотой опроса qBittorrent — быстрее
|
||||
источника опрашивать бессмысленно), прочие наблюдаемые — реже (`pollSlow`).
|
||||
- **Тик ходит в БД, и это цена решения.** Живые цифры берутся из in-memory
|
||||
снимка воркера (`LiveStatus.Live(infohash)`), но состояние и размер раскладки
|
||||
тик читает из хранилища, а тик страницы загрузки ещё и считает предпросмотр
|
||||
раскладки с обходом ФС — отсюда и разные интервалы. Узкий контракт
|
||||
`LiveStatus` при этом не зависит от способа доставки (поллинг сейчас, путь к
|
||||
SSE оставлен изолированным).
|
||||
- **Инвариант: браузер не опрашивает qBittorrent напрямую** — только свой сервер,
|
||||
который читает снимок. Поллинг статуса UI логируем на `DEBUG` (рутинно-частое,
|
||||
см. [logging.md](logging.md)).
|
||||
|
||||
+14
-1
@@ -207,5 +207,18 @@ erDiagram
|
||||
| `[llm].timeout` / `max_retries` | `120s` / `3` | каждая попытка порождает строку `recognition` с сырым ответом |
|
||||
| `[metadata.*].timeout` | `10s` | таймаут запроса к метабазе |
|
||||
|
||||
Пределы, зашитые константой кода, а не полем конфига:
|
||||
|
||||
| Константа | Значение | Что означает |
|
||||
| --- | --- | --- |
|
||||
| `ingest.MaxTorrentSize` | `8 MiB` | предел размера принимаемого `.torrent`; проверяется **до** разбора, поэтому bencode-аллокации на эту величину не масштабируются (см. [research/torrent-bencode-limits.md](research/torrent-bencode-limits.md)) |
|
||||
| `httpapi.pollFast` | `5s` | интервал самообновления поверхности с живыми цифрами качания (карточка в `downloading`). Держится вровень с `[worker].poll_interval`: снимок телеметрии обновляется тиком воркера, и опрос чаще возвращает тот же снимок. Меняется `poll_interval` — меняется и эта константа |
|
||||
| `httpapi.pollSlow` | `15s` | интервал самообновления прочих наблюдаемых поверхностей: карточек вне `downloading` и страницы `/download/{id}` в любом состоянии. Тик страницы считает предпросмотр раскладки и ходит в ФС, поэтому частота у него ниже |
|
||||
| `httpapi.maxBulkDelete` | `20` загрузок | предел размера одной пачки группового удаления. Подтверждение, перечисляющее больше, человек не читает — то есть перестаёт быть подтверждением; плюс один синхронный запрос упирается в столько же последовательных вызовов qBittorrent. Предел называет сама страница выбора; отказ по пределу возвращает выбор с сохранёнными отметками |
|
||||
| `httpapi.bulkFailThreshold` | `3` отказа подряд | сколько подряд идущих отказов внешнего сервиса прекращают проход группового удаления. Удаление снимает библиотечные ссылки раньше, чем сносит раздачу: при недоступном qBittorrent каждая единица успевает выполнить необратимый локальный шаг и упасть на внешнем. Счётчик сбрасывается на успехе; конфликт состояния системным отказом не считается |
|
||||
| `httpapi.bulkBudget` | `2` минуты | потолок времени на один проход группового удаления. Удаление держит общий замок воркера на всё время обращения к qBittorrent, поэтому медленно, но успешно отвечающий сосед остановил бы фоновую работу целиком, а порог отказов такого не ловит. Проверяется между единицами: начатое удаление не обрывается, иначе оно встанет между снятием ссылок и сносом раздачи |
|
||||
| `layout.maxComponentBytes` | `255` байт | предел длины компонента целевого пути (`NAME_MAX` у ext4/xfs/btrfs); меряется в байтах UTF-8, проверяется **до** первой операции с ФС, отказ уводит задачу в `review` с кодом `name_too_long`. У ядра не выясняется; на ФС с меньшим пределом остаётся отказ ядра — лечение правкой константы, а не настройкой |
|
||||
|
||||
**Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет,
|
||||
кэша метабаз нет — всё три пункта в беклоге.
|
||||
кэша метабаз нет; состояние по каждому пробелу и заведённые под них задачи —
|
||||
[architecture.md](architecture.md) → «Открытые вопросы».
|
||||
|
||||
+9
-3
@@ -1,7 +1,7 @@
|
||||
# Паспорт проекта
|
||||
|
||||
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
|
||||
устроено», [tasks/PLAN.md](tasks/PLAN.md) — «в каком порядке», паспорт —
|
||||
устроено», [tasks/ROADMAP.md](../tasks/ROADMAP.md) — «что уже умеет», паспорт —
|
||||
«зачем и для кого».
|
||||
|
||||
## Цель
|
||||
@@ -44,8 +44,14 @@ 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)).
|
||||
|
||||
|
||||
+20
-6
@@ -1,7 +1,13 @@
|
||||
# Разведка
|
||||
|
||||
Наблюдения за внешним миром: что реально шлёт источник, чем документация формата
|
||||
расходится с практикой. Источник истины — этот каталог, а не чужая документация.
|
||||
расходится с практикой и как ведут себя наши разборщики и зависимости на границе
|
||||
формата. Источник истины — этот каталог, а не чужая документация.
|
||||
|
||||
Наблюдение о чужом **коде** живёт здесь наравне с наблюдением о чужих
|
||||
**данных**, но у него есть срок годности: такая записка обязана называть версию
|
||||
зависимости и условие пересмотра, потому что протухает от обновления `go.mod`, а
|
||||
не от смены формата.
|
||||
|
||||
**Каждый вывод — с числами и командой или условиями, которыми получен**, чтобы
|
||||
его можно было перепроверить. Число без провенанса проход обязан читать как
|
||||
@@ -14,14 +20,22 @@
|
||||
Наблюдения снимались вручную, по ходу разработки, на домашнем контуре: реальные
|
||||
сообщения торрент-бота в Telegram, реальные ответы qBittorrent WebUI API и
|
||||
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) — формат сообщения
|
||||
торрент-бота, из которого приходит 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`, **живым прогоном
|
||||
не подтверждено**.
|
||||
|
||||
@@ -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`).
|
||||
@@ -72,4 +72,6 @@ cправка: /help, index (https://exfreedomist.com/stats/)
|
||||
- Как выглядит сообщение для сериала-сезонника и для аниме — образцов не
|
||||
снимали, а именно на них строится самый сложный случай раскладки.
|
||||
- Что бот присылает при неудачном поиске и при слишком длинном описании
|
||||
(обрезка Telegram — 4096 символов на сообщение).
|
||||
(лимит Telegram на текст сообщения — 4096 символов UTF-16, по документации
|
||||
Bot API `sendMessage`; сами обрезанные сообщения мы не наблюдали, поведение
|
||||
бота на обрезке неизвестно).
|
||||
|
||||
@@ -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
@@ -1,11 +1,34 @@
|
||||
# Ревью: настройка и журнал
|
||||
|
||||
Проектная часть конвейера ревью: чем 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`).
|
||||
|
||||
**Вызов LLM и разбор его ответа** (`recognize`, `naming.Derive`)
|
||||
|
||||
Самый специфичный род узла в проекте: единственный, чей выход недетерминирован,
|
||||
недоверен и стоит денег одновременно.
|
||||
|
||||
- решение узла **не является гейтом безопасности**: инъекция в промпт считается
|
||||
состоявшейся, защита стоит ниже — на валидации целевого пути
|
||||
([security.md](security.md));
|
||||
- самооценка модели не заменяет матч в метабазе — порог `confidence` стоит
|
||||
**поверх** матча дополнительным условием, а не вместо него;
|
||||
- разбор ответа устойчив к лишним и недостающим полям, обрезке и не-JSON;
|
||||
негодный ответ даёт доменную ошибку, а не тихий дефолт;
|
||||
- попытка стоит лимита и денег: число ретраев берётся из конфига, а фоновый цикл
|
||||
не запускает распознавание сам по себе повторно;
|
||||
- тест не ходит в живой эндпоинт — провайдер за интерфейсом, ответ фикстурой;
|
||||
живые прогоны только за env-гейтом.
|
||||
|
||||
**htmx-хендлер**
|
||||
|
||||
- один партиал обслуживает страницу и фрагмент, ветвление по `isHTMX`;
|
||||
@@ -70,10 +110,12 @@ Go-сервиса и что здесь уже проскакивало. Устр
|
||||
снаружи LAN.
|
||||
- **«Ошибка на htmx-пути возвращает 200».** Так и задумано —
|
||||
[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).
|
||||
Самооценка LLM плохо откалибрована и поддаётся инъекции.
|
||||
Самооценка LLM плохо откалибрована и поддаётся инъекции. Порог `confidence`
|
||||
при этом существует **дополнительным** блокирующим условием поверх матча —
|
||||
его наличие дефектом не является.
|
||||
- **«Копировать надёжнее, чем хардлинк» / «взять симлинк».** Хардлинк —
|
||||
осознанный выбор ради неприкосновенности источника и недублирования диска,
|
||||
[ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md); copy — только
|
||||
@@ -91,86 +133,176 @@ Go-сервиса и что здесь уже проскакивало. Устр
|
||||
`original_title` заполняется всегда и при неуверенности дублирует `title` —
|
||||
это контракт capability `recognition`, а не недосмотр.
|
||||
|
||||
### Вопросы к проходам
|
||||
### Вопросы по темам
|
||||
|
||||
Форма: `<имя прохода>: <вопрос> (<провенанс>)`. Журнал дефектов пока пуст,
|
||||
поэтому провенанс у всех пунктов — инвариант или ADR, а не пойманный случай;
|
||||
по мере накопления журнала список должен смещаться в сторону реальных промахов.
|
||||
Форма: `<тема>: <вопрос> (<провенанс>)`. Адресуется теме, а не имени прохода:
|
||||
проход переезжает между метками и упраздняется, тема переезд переживает. Задаёт
|
||||
вопрос тот, кто закрывает тему на текущем прогоне.
|
||||
|
||||
- `adversary`: можно ли, управляя только именами файлов в раздаче и текстом
|
||||
- `security`: можно ли, управляя только именами файлов в раздаче и текстом
|
||||
контекста, добиться целевого пути вне `paths.movies`/`series` — включая путь
|
||||
через юникод, длину сверх лимита ФС и коллизию после нормализации?
|
||||
(инвариант «целевой путь строго под библиотекой», [security.md](security.md))
|
||||
- `adversary`: есть ли последовательность команд, после которой снимается
|
||||
- `security`: есть ли последовательность команд, после которой снимается
|
||||
**последняя** копия данных — с учётом `superseded`-ссылок и гонки со сверкой?
|
||||
(инвариант «источник неприкосновенен»)
|
||||
- `adversary`: что даёт крафт-магнет с чужим или подставным инфохэшем —
|
||||
- `security`: что даёт крафт-магнет с чужим или подставным инфохэшем —
|
||||
присоединение к чужой активной загрузке, отравление владения?
|
||||
(открытая задача про идентичность инфохэшей)
|
||||
- `ops`: что делает эта ветка, когда qBittorrent недоступен несколько минут
|
||||
подряд — сколько ERROR-строк в секунду и меняется ли состояние задач?
|
||||
- `security`: где признак «это наше» снимается с одной сущности, а действие
|
||||
применяется к другой — присутствие раздачи в qBittorrent против байтов на
|
||||
диске, запись в БД против файла, инфохэш против содержимого? (журнал,
|
||||
2026-08-06: уборка своего торрента сносила чужие файлы)
|
||||
- `operations`: что делает эта ветка, когда qBittorrent недоступен несколько
|
||||
минут подряд — сколько ERROR-строк в секунду и меняется ли состояние задач?
|
||||
(задача про ERROR-шторм фоновых циклов)
|
||||
- `ops`: как это ведёт себя при сотне загрузок в базе и десятках тысяч
|
||||
- `operations`: как это ведёт себя при сотне загрузок в базе и десятках тысяч
|
||||
`file_link` — есть ли запрос без индекса и полный проход по таблице?
|
||||
(задача про масштаб 100/1000, [database.md](database.md) → «Настройки»)
|
||||
- `ops`: что остаётся на диске и в базе, если процесс убит посреди раскладки
|
||||
батча? (состояние `linking` и его восстановление)
|
||||
- `code`: логирующий чекпоинт один на операцию — или ошибка залогирована и
|
||||
возвращена вверх, где залогирована снова?
|
||||
- `operations`: что остаётся на диске и в базе, если процесс убит посреди
|
||||
раскладки батча? (состояние `linking` и его восстановление)
|
||||
- `autotests`: изменённые строки не просто исполнены тестом, а **проверены** —
|
||||
есть ли тест, который упал бы без этой правки? (diff-coverage в гейте меряет
|
||||
исполнение, отличить его от проверки машина не может)
|
||||
- `autotests`: конкурентная часть правки проверена тестом, а не рассуждением?
|
||||
(`-race` без gcc уходит в `SKIP`, и тогда гонки не проверял никто —
|
||||
[CLAUDE.md](../CLAUDE.md) → «Гейт»)
|
||||
- `autotests`: новый тест не ходит в живой qBittorrent, LLM, метабазу или
|
||||
Telegram — а если ходит, он за env-гейтом в `*_integration_test.go`?
|
||||
([CLAUDE.md](../CLAUDE.md) → «Запреты»)
|
||||
- `conventions`: логирующий чекпоинт один на операцию — или ошибка залогирована
|
||||
и возвращена вверх, где залогирована снова?
|
||||
([conventions/logging.md](conventions/logging.md))
|
||||
- `code`: новое поле конфига появилось в `config.example.toml` с описанием
|
||||
назначения, диапазона и единиц? ([conventions/config.md](conventions/config.md))
|
||||
- `specs`: не завелось ли поведение, которого спека не заказывала — тихий
|
||||
- `conventions`: новое поле конфига появилось в `config.example.toml` с
|
||||
описанием назначения, диапазона и единиц?
|
||||
([conventions/config.md](conventions/config.md))
|
||||
- `requirements`: не завелось ли поведение, которого спека не заказывала — тихий
|
||||
дефолт, проглоченная ошибка, ретрай «на всякий случай», отброшенное поле?
|
||||
- `security`: читается ли тело ответа внешнего сервиса целиком без предела —
|
||||
у LLM (8 MiB) и метабаз (4 MiB) предел стоит, и новый исходящий вызов обязан
|
||||
заводить свой ([security.md](security.md) → «Что вне модели»)
|
||||
- `operations`: гарантия, которую вводит изменение, поставлена на запись или на
|
||||
чтение — и что будет с данными, записанными до деплоя, которые обычный путь
|
||||
не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и
|
||||
очередь ревью её обходила)
|
||||
- `operations`: новая проверка встала в общую точку — что она отняла у тех, кто
|
||||
зовёт эту точку не ради проверки? Пропала ли команда, стал ли предпросмотр пустым, перестал ли экран объяснять
|
||||
причину, и узнает ли человек причину на **каждом** входе, а не только
|
||||
на том, который разбирали? (журнал, 2026-08-10: проверка длины погасила
|
||||
«Применить» и ничего не объяснила)
|
||||
- `operations`: не удваивает ли новая ветка расход лимита метабаз и платного
|
||||
LLM — повтор, ретрай, «распознать заново» на том же входе? (кэша ответов нет,
|
||||
задача `metadata-cache`)
|
||||
- `operations`: копится ли то, что заводит это изменение, без срока хранения —
|
||||
строки терминальных задач, сырые ответы, файлы? (авточистки в проекте нет,
|
||||
задача `db-retention-cleanup`)
|
||||
- `architecture`: не появился ли второй способ делать то, что уже делается —
|
||||
второе место, где генерится время или id, второй парсер источника, вторая
|
||||
логика целевых имён мимо `naming`?
|
||||
([architecture.md](architecture.md) → «Единые точки проекта»)
|
||||
|
||||
### Триггеры профиля
|
||||
### Триггеры метки
|
||||
|
||||
Уточняет умолчания конвейера, не отменяет их.
|
||||
Уточняет умолчания конвейера, не отменяет их. Рабочее умолчание — `medium`;
|
||||
миграция схемы и публичный контракт метку **не** поднимают: их проверяют
|
||||
проходы, которые в `medium` и так есть. Списка три: два поднимают до `large`,
|
||||
по одному на ось, третий опускает до `small`.
|
||||
|
||||
**Крупное здесь** — про объём, сколько узлов и слоёв трогает изменение:
|
||||
|
||||
- перенос ответственности между `worker`, `recognition`, `layout` и `store`;
|
||||
- новое состояние в графе переходов загрузки: оно тянет за собой воркер, спеку,
|
||||
отображение в веб-UI и боте и восстановление после рестарта;
|
||||
- правка, идущая насквозь по цепочке приём → распознавание → раскладка;
|
||||
- новый провайдер метабазы за существующим интерфейсом: клиент, поле конфига с
|
||||
образцом, слияние полей кандидата, ветка «провайдера нет».
|
||||
|
||||
**Незнакомое здесь** — про форму решения, которую предстоит нащупать по ходу:
|
||||
|
||||
- новый пакет `internal/*` или новая capability в `openspec/specs/`;
|
||||
- новый транспорт приёма или уведомлений рядом с REST, веб-UI, ботом и CLI;
|
||||
- заводится или меняется **правило идентичности, слияния или разбора**: ключ
|
||||
владения раздачей и сверка с qBittorrent (`ident`, `internal/store`,
|
||||
`state-reconciliation`); построение целевых путей и санитизация имён
|
||||
(`internal/layout`, `naming`); новый вид входа или новая ветка неоднозначности
|
||||
у разбора недоверенного — bencode, magnet, текст контекста, ответ LLM
|
||||
(`internal/torrent`, `internal/magnet`, `internal/tgbot/parse.go`); новый
|
||||
источник или новый победитель при конфликте в слиянии кандидата метабазы
|
||||
(`internal/metadata`, `metadata-match`); merge-раскладка при повторном
|
||||
добавлении раздачи.
|
||||
|
||||
- **новая поверхность поверх необратимой операции** — вторая точка входа в
|
||||
команду, которая удаляет файлы или снимает последнюю копию. Форма решения
|
||||
здесь нащупывается по ходу: подтверждение, порядок отказов, остаток,
|
||||
наблюдаемость. Выведено по факту на `bulk-delete-page` (журнал, 2026-08-10):
|
||||
метка `medium` не дала ни враждебного прохода, ни замера, а именно они нашли
|
||||
четыре дефекта класса «необратимо».
|
||||
|
||||
- **`deep`** — есть миграция в `internal/store/migrations/`; появляется новый
|
||||
пакет `internal/*`; меняется сигнатура публичной команды воркера; трогается
|
||||
раскладка файлов, построение целевых путей или удаление ссылок; трогается
|
||||
разбор недоверенного входа.
|
||||
- **`standard`** — меняется поведение, видимое снаружи: REST-эндпоинт,
|
||||
htmx-путь, набор или семантика состояний загрузки, формат сообщения бота,
|
||||
поле конфига.
|
||||
- **`quick`** — всё остальное: локальный багфикс, документация, тесты.
|
||||
- **Независимая реализация (`reimpl`)** запускается, когда узел одновременно
|
||||
новый и имеет внешний оракул в виде спеки: новый парсер, новый провайдер за
|
||||
существующим интерфейсом, новая стадия конвейера распознавания.
|
||||
- «Поведение, видимое снаружи» здесь включает **тексты и карточки Telegram** —
|
||||
для единственного пользователя это и есть интерфейс.
|
||||
|
||||
**Место из перечня метку не поднимает — поднимает правило.** Перечни выше
|
||||
отвечают «здесь такие правила водятся», а не «любая правка здесь идёт в
|
||||
`large`». Метку поднимает то, что даёт работу новому проходу: заводится ключ
|
||||
сравнения или меняется его состав; у разбора появляется новый вид входа или
|
||||
новая ветка неоднозначности; в слияние добавляется источник или меняется
|
||||
победитель при конфликте.
|
||||
|
||||
**Мелкое здесь** — опускает до `small`. Перечень закрытый, каждый пункт
|
||||
проверяется взглядом на дифф и дельта-спеку, любое сработавшее держит метку
|
||||
внизу:
|
||||
|
||||
- **дельта-спека называет исход поимённо** — сценарий уже говорит, что даёт
|
||||
вырожденный вход, и решать в коде нечего;
|
||||
- **новых сценариев в дельта-спеке нет** — изменение уточняет уже описанное
|
||||
поведение, а не заказывает новое;
|
||||
- **правка сообщения, комментария, записи журнала, имени или теста** в узле из
|
||||
перечней выше;
|
||||
- **сужение уже существующей нормализации** без нового вида входа: вход остался
|
||||
тот же, изменился исход на одном его значении.
|
||||
|
||||
Отрицательный тест поверх перечня: что после мерджа не откатывается обратной
|
||||
правкой — миграция, формат на диске, публичный контракт, имя, — **не** `small`,
|
||||
каким бы маленьким ни был дифф.
|
||||
|
||||
**Ориентир частоты.** `medium` закрывает большинство задач, `large` рассчитана
|
||||
на 5–10% и приходится на крупную функциональность, а не на уборку: задача типа
|
||||
`chore` или `fix`, собранная из нитей прошлого ревью, идёт в `small` или
|
||||
`medium`, даже когда трогает файл из перечней выше. `large` чаще одной задачи из
|
||||
десяти означает ошибку в критерии, а не полосу сложных задач подряд.
|
||||
|
||||
### Недоступно проверке
|
||||
|
||||
**Не проверит ни один проход** — принципиальная граница, по факту промаха не
|
||||
пересматривается.
|
||||
|
||||
- История инцидентов на umbar и то, что уже ломалось в проде.
|
||||
- Поведение таблицы SQLite под реальным объёмом и профилем нагрузки: реального
|
||||
профиля нет ни у кого, кроме сервера.
|
||||
- Завязка внешних потребителей (Jellyfin, закладки, чужие ссылки) на текущее
|
||||
поведение.
|
||||
- Качество распознавания как таковое: правильно ли LLM определил фильм — вопрос
|
||||
eval-харнеса и корпуса кейсов, а не ревью кода.
|
||||
- Суждение «этой функциональности не должно существовать».
|
||||
- `operations`: история инцидентов на umbar и то, что уже ломалось в проде.
|
||||
- `operations`: поведение таблицы SQLite под реальным объёмом и профилем
|
||||
нагрузки — реального профиля нет ни у кого, кроме сервера.
|
||||
- `architecture`: завязка внешних потребителей (Jellyfin, закладки, чужие
|
||||
ссылки) на текущее поведение.
|
||||
- `architecture`: суждение «этой функциональности не должно существовать».
|
||||
- `requirements`: качество распознавания как таковое — правильно ли LLM
|
||||
определил фильм. Это вопрос тюнинга модели и промпта, а не ревью кода;
|
||||
размеченный корпус, по которому это можно было бы судить числом, решено не
|
||||
собирать (`tasks/REJECTED.md`, 2026-08-06).
|
||||
|
||||
**Перестали проверять сознательно** — пересматривается первым, как только
|
||||
что-то проскочило.
|
||||
|
||||
- **Идиоматичность Go — с 2026-08-04.** Проектный проход `idiom` (поимённая
|
||||
сверка с положениями Effective Go, Go Code Review Comments, стайлгайдов Uber
|
||||
и Google) удалён вместе с проектными копиями агентов при переезде на плагин
|
||||
`av-dev-pipeline`, который этот проход упразднил. Способные части переселены:
|
||||
эксперимент против поведения библиотеки и драйвера — в `ops`, «не изобретаем
|
||||
ли то, что уже есть в библиотеке» — в `architecture`. **Различение
|
||||
«идиоматично против распространено» теперь не спрашивает никто.** Класс
|
||||
обратимый: портит форму кода, не данные. Пересмотр — задача
|
||||
`quality-review-agents`.
|
||||
- `conventions`: **идиоматичность Go — с 2026-08-04.** Проектный проход `idiom`
|
||||
(поимённая сверка с положениями Effective Go, Go Code Review Comments,
|
||||
стайлгайдов Uber и Google) упразднён вместе с переездом конвейера в плагин
|
||||
([ADR-2026-08-04-review-pipeline-to-plugin](adr/ADR-2026-08-04-review-pipeline-to-plugin.md));
|
||||
способные части переселены — в тему `operations` (эксперимент против
|
||||
поведения библиотеки и драйвера) и в `architecture` («не изобретаем ли то,
|
||||
что уже есть в библиотеке»). **Различение «идиоматично против
|
||||
распространено» теперь не спрашивает никто.** Класс обратимый: портит форму
|
||||
кода, не данные. Пересмотр — задача `quality-review-agents`.
|
||||
- `security`, `operations`, `architecture`: на метках `small` и `medium` не
|
||||
проверяется ничто, требующее запуска, — построенных путей атаки, замеров и
|
||||
эксплуатационного постмортема там нет по устройству конвейера. Их даёт только
|
||||
`large`, а она приходится на 5–10% задач.
|
||||
|
||||
## Журнал дефектов
|
||||
|
||||
@@ -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));
|
||||
случаи до этой даты не восстанавливались — восстановленная постфактум причина
|
||||
непоймания недостоверна, а именно она и нужна.
|
||||
|
||||
## 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`,
|
||||
находки 2–4
|
||||
- **Почему не поймали:** проходы, находящие этот класс, живут в метке `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
@@ -29,9 +29,10 @@ REST API работают **без авторизации** осознанно;
|
||||
| Текстовый контекст человека | все транспорты | попадает в промпт LLM целиком |
|
||||
| Сообщение торрент-бота | Telegram (пересылка) | чужой формат, парсер, ссылки; текст автора бота, а не отправителя |
|
||||
| **Ответ 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 | пути, состояния, размеры |
|
||||
| Запросы веб-UI и REST | LAN | идентификаторы, параметры действий |
|
||||
| Пачка идентификаторов и признак подтверждения на групповом удалении | форма веб-UI | необратимое действие сразу по многим загрузкам; разбор, схлопывание дублей и предел размера стоят на **обеих** границах — подтверждении и исполнении, потому что вторая получает пачку формой заново |
|
||||
|
||||
**Выход LLM не отвечает за безопасность.** Инъекция в промпт считается
|
||||
состоявшейся по умолчанию; защита стоит ниже — на валидации целевого пути.
|
||||
@@ -49,7 +50,10 @@ REST API работают **без авторизации** осознанно;
|
||||
- **Правило:** компоненты санитизируются (убираются разделители пути, `..`,
|
||||
управляющие символы), финальный `filepath.Clean`-путь обязан быть **строго
|
||||
под** соответствующей библиотекой, иначе операция отклоняется. Проверка на
|
||||
результате, а не на входе.
|
||||
результате, а не на входе. Следом — длина: каждый компонент обязан помещаться в
|
||||
255 байт UTF-8, иначе задача уходит в `review`. Порядок значим: путь, вышедший
|
||||
за песочницу, отклоняется как выход за библиотеку, а не как длинное имя, иначе
|
||||
находка безопасности спряталась бы за косметической причиной.
|
||||
- **Исходный путь** = `save_path` из qBittorrent + относительное имя файла из
|
||||
`/torrents/files`. Тоже недоверенный, но по нему мы только **читаем и
|
||||
линкуем**; писать в `paths.downloads` нельзя вообще.
|
||||
@@ -100,7 +104,8 @@ REST API работают **без авторизации** осознанно;
|
||||
- **Отказ в обслуживании изнутри контура.** Огромная раздача, тысяча файлов,
|
||||
бесконечный ответ LLM — это вопросы устойчивости и ресурсов
|
||||
([architecture.md](architecture.md) → «Эксплуатация»), а не безопасности.
|
||||
Отсутствие лимита на размер ответа LLM — известный пробел, задача в беклоге.
|
||||
Тело ответа внешнего сервиса при этом читается с пределом: LLM — 8 MiB
|
||||
(`internal/llm`), метабазы — 4 MiB (`internal/metadata`).
|
||||
- **Целостность содержимого медиафайлов.** Что в контейнере mkv — не наша забота.
|
||||
- **Цепочка поставки** — модули Go, базовый образ distroless, плагины тулинга.
|
||||
- **Приватность запросов к внешним сервисам.** Названия раздач уезжают в LLM и
|
||||
|
||||
@@ -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, время) — надо решить, что из них стало реальным трением, а что выдумано вперёд
|
||||
@@ -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 и кодом, а конвейер ревью не откалиброван — растёт цена каждой следующей задачи
|
||||
@@ -1,7 +0,0 @@
|
||||
# Ушедшее без реализации
|
||||
|
||||
Задачи, покинувшие беклог **без реализации**, с причиной и датой.
|
||||
Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них
|
||||
есть коммит. Это первое место, куда смотрит дедупликация при заведении.
|
||||
|
||||
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->
|
||||
@@ -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'а.
|
||||
@@ -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.
|
||||
@@ -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` — требование «Ручное закрытие»
|
||||
@@ -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).
|
||||
@@ -1,11 +0,0 @@
|
||||
# [goal] Точность распознавания
|
||||
|
||||
- **Секция:** темы
|
||||
- **Зачем:** смена модели или правка промпта сегодня вслепую — нет ни метрики, ни способа переиспользовать уже сделанные человеком правки
|
||||
- **Теги:** decomposed
|
||||
|
||||
Ради чего: распознавание — единственное место, где система может ошибиться молча и правдоподобно. Сегодня её точность не измеряется ничем, кроме впечатления, а накопленные правки человека пропадают.
|
||||
|
||||
## Завершение
|
||||
|
||||
Достигнута, когда точность распознавания меряется числом на фиксированном корпусе реальных раздач, смена модели или правка промпта прогоняются через этот корпус до выкатки, а решение auto/review опирается на измеримую силу совпадения, а не на самооценку модели.
|
||||
@@ -1,9 +0,0 @@
|
||||
# Eval-харнес распознавания (корпус кейсов + метрика точности)
|
||||
|
||||
- **Секция:** инфраструктура
|
||||
- **Зачем:** смена модели или правка промпта распознавания сейчас вслепую — нет корпуса кейсов и метрики точности, регрессии не видно
|
||||
- **Теги:** goal:recognition-accuracy
|
||||
|
||||
Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую: регрессий не видно. Нужен корпус размеченных кейсов (русские релизы, аниме, сезон-паки, репаки, спецвыпуски) и прогон распознавания по нему с метрикой точности (тип/название/год/нумерация). Тогда можно сравнивать LLM-провайдеры и версии промпта по числам. Прогон — отдельной командой (jellybit eval или тестом), на фикстурах, без реального qBittorrent.
|
||||
|
||||
Связано: specs/recognition.md (конвейер, модель уверенности), пакет recognize.
|
||||
@@ -284,8 +284,8 @@ func (c *Config) validate() error {
|
||||
// Язык локализованного вывода: пусто (→ en) или один из кодов. Fail-fast,
|
||||
// как llm.type: мусорное значение не должно молча дефолтить. Множество
|
||||
// {ru, en} — канон; при добавлении кода синхронно расширь мапперы
|
||||
// metadata.tmdbLocale и recognize.languageDirective, иначе новый язык молча
|
||||
// даст английский вывод.
|
||||
// metadata.tmdbLocale, metadata.tvdbLocale и recognize.languageDirective,
|
||||
// иначе новый язык молча даст английский вывод.
|
||||
switch c.General.Language {
|
||||
case "", "ru", "en":
|
||||
default:
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -337,8 +338,9 @@ func TestSourceSwapUpdatesActionBarOOB(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestRetryListShowsProgress: retry из списка → карточка downloading с
|
||||
// прогресс-поллером.
|
||||
// TestRetryListShowsProgress: retry из списка → карточка downloading с живым
|
||||
// прогрессом и самообновлением карточки (опрашивает себя карточка, а не
|
||||
// вложенный блок прогресса).
|
||||
func TestRetryListShowsProgress(t *testing.T) {
|
||||
dl := dlState(store.StateDownloading)
|
||||
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 {
|
||||
t.Fatalf("retry (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "/fragments/downloads/"+testULID+"/progress") {
|
||||
t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String())
|
||||
body := 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 + "."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ type downloadDetailView struct {
|
||||
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
|
||||
Context string
|
||||
State string
|
||||
SelfPoll bool // catched → страница сама опрашивает себя до перехода
|
||||
SelfPoll bool // задача наблюдаема → страница сама опрашивает себя
|
||||
PollEvery string
|
||||
Error string
|
||||
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
|
||||
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)
|
||||
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) {
|
||||
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||
return
|
||||
@@ -113,7 +123,10 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
||||
Infohashes: d.HashList(),
|
||||
Context: d.Context,
|
||||
State: string(d.State),
|
||||
SelfPoll: d.State == store.StateCatched,
|
||||
SelfPoll: d.State.IsObservable(),
|
||||
// Блока живых цифр качания на странице нет вовсе, а тик считает
|
||||
// предпросмотр раскладки и ходит в ФС — интервал всегда медленный.
|
||||
PollEvery: pollSlow,
|
||||
Error: d.ErrorMsg.String,
|
||||
Note: desyncNote(d.State),
|
||||
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 ||
|
||||
d.State == store.StateTargetMissing,
|
||||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
||||
Deletable: d.State == store.StateDone || d.State == store.StateOrphaned ||
|
||||
d.State == store.StateTargetMissing,
|
||||
Deletable: d.State.CanDelete(),
|
||||
Dismissable: d.State.IsTerminal() &&
|
||||
d.State != store.StateDeleted && d.State != store.StateCancelled,
|
||||
}
|
||||
|
||||
+74
-21
@@ -18,6 +18,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -52,6 +53,9 @@ type Reader interface {
|
||||
// LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из
|
||||
// загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке).
|
||||
LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error)
|
||||
// ListDeletableDownloads — загрузки, разрешённые к полному удалению, без
|
||||
// постраничной выдачи (страница группового удаления показывает их разом).
|
||||
ListDeletableDownloads(ctx context.Context) ([]store.Download, error)
|
||||
}
|
||||
|
||||
// Deps — зависимости транспорта.
|
||||
@@ -113,16 +117,22 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
// Веб-UI.
|
||||
r.Get("/", s.handleIndex)
|
||||
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}/seeding", s.handleFragSeeding)
|
||||
// Карточка целиком: самополлинг catched до перехода в downloading (бейдж,
|
||||
// имя и появившийся прогресс обновляются без перезагрузки).
|
||||
// Карточка целиком — тик самообновления списка: пока задача наблюдаема,
|
||||
// карточка приносит текущее состояние без перезагрузки страницы.
|
||||
r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
|
||||
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
|
||||
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
|
||||
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}/retry", s.handleUIRetry)
|
||||
|
||||
@@ -205,8 +215,9 @@ type downloadView struct {
|
||||
State string
|
||||
Error string
|
||||
Terminal bool
|
||||
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
||||
SelfPoll bool // catched → карточка сама опрашивает себя до перехода
|
||||
IsDownloading bool // активная загрузка → живой прогресс-бар
|
||||
SelfPoll bool // задача наблюдаема → карточка сама опрашивает себя
|
||||
PollEvery string // интервал самообновления карточки (pollFast/pollSlow)
|
||||
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
||||
Reviewable bool // review/deferred — есть экран ревью
|
||||
Undoable bool // done — можно откатить раскладку
|
||||
@@ -311,10 +322,11 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// buildCardView собирает представление карточки списка из доменных данных и
|
||||
// живого снимка. Общий для полной страницы (handleIndex) и htmx-свопа карточки
|
||||
// после действия (renderCardFragment): чтобы htmx-ветка не дублировала обвязку
|
||||
// (рейтинг/размер/прогресс). Для retry→downloading карточка обязана нести
|
||||
// прогресс-поллер — поэтому Progress заполняется здесь.
|
||||
// живого снимка. Общий для полной страницы (handleIndex), тика самообновления
|
||||
// (handleFragCard) и htmx-свопа после действия (renderCardFragment): чтобы
|
||||
// htmx-ветки не дублировали обвязку (рейтинг/размер/прогресс). Живые цифры едут
|
||||
// вместе с карточкой — своего опроса у блока прогресса нет, поэтому Progress
|
||||
// заполняется здесь на каждом пути.
|
||||
func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView {
|
||||
v := s.toView(d, now)
|
||||
// Живой снимок читаем для всех карточек (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)
|
||||
if err != nil {
|
||||
redirectErr(w, r, userErr(r, err, res.DownloadID))
|
||||
// Нулевой Result на любом пути ошибки — контракт ingest.Ingest;
|
||||
// корреляционный ключ веб-формы, как и REST, — request_id.
|
||||
redirectErr(w, r, userErr(r, err, ""))
|
||||
return
|
||||
}
|
||||
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) {
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
s.fragActionErr(w, err, id, "card-"+id)
|
||||
return
|
||||
}
|
||||
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) {
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
s.fragActionErr(w, err, id, "download-main")
|
||||
return
|
||||
}
|
||||
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})
|
||||
if err != nil {
|
||||
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit) —
|
||||
// тогда коррелируем по download_id, иначе (ранний разбор источника) по
|
||||
// request_id.
|
||||
s.apiErr(w, r, err, res.DownloadID)
|
||||
// Приём на любом пути ошибки возвращает нулевой Result (контракт
|
||||
// ingest.Ingest) — идентификатора загрузки тут нет и быть не может,
|
||||
// коррелируем по request_id.
|
||||
s.apiErr(w, r, err, "")
|
||||
return
|
||||
}
|
||||
status := http.StatusCreated
|
||||
@@ -659,7 +673,8 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
|
||||
Error: d.ErrorMsg.String,
|
||||
Terminal: d.State.IsTerminal(),
|
||||
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,
|
||||
Undoable: d.State == store.StateDone,
|
||||
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,
|
||||
Note: desyncNote(d.State),
|
||||
}
|
||||
// Быстрый интервал — только там, где на поверхности бегут цифры качания.
|
||||
if v.IsDownloading {
|
||||
v.PollEvery = pollFast
|
||||
}
|
||||
// Дата добавления в карточке — всегда (source_added_at → фолбэк created_at,
|
||||
// как в порядке списка); неразбираемое время просто опускаем.
|
||||
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 {
|
||||
if d.DisplayName != "" {
|
||||
return d.DisplayName
|
||||
return displaySafe(d.DisplayName)
|
||||
}
|
||||
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 схлопывает переводы строк и лишние пробелы — сырой источник в
|
||||
@@ -768,7 +811,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и
|
||||
// некорректный ввод команды (worker.ErrInvalidInput) →
|
||||
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
|
||||
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее
|
||||
// (layout.ErrCollision), непомещающееся целевое имя (layout.ErrNameTooLong) и
|
||||
// конфликт состояния (worker.ErrConflict) → 409; прочее
|
||||
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
|
||||
// сообщение + корреляционный ключ.
|
||||
func classifyErr(err error) (int, string) {
|
||||
@@ -791,6 +835,10 @@ func classifyErr(err error) (int, string) {
|
||||
// Целевой путь уже занят: задача штатно ушла в review с причиной —
|
||||
// это не сбой, а требующий разбора конфликт.
|
||||
return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью"
|
||||
case errors.Is(err, layout.ErrNameTooLong):
|
||||
// Целевое имя не помещается в файловую систему: задача штатно ушла в
|
||||
// review, где название правится подсказкой. Не сбой сервера.
|
||||
return http.StatusConflict, "целевое имя слишком длинное, задача отправлена в ревью"
|
||||
case errors.Is(err, worker.ErrConflict):
|
||||
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||||
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
||||
@@ -799,6 +847,11 @@ func classifyErr(err error) (int, string) {
|
||||
return http.StatusBadRequest, errManualSource.Error()
|
||||
case errors.Is(err, errInvalidCandidate):
|
||||
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:
|
||||
return http.StatusInternalServerError, "внутренняя ошибка"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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 {
|
||||
t.Helper()
|
||||
@@ -1098,3 +1101,25 @@ func TestRerecognize(t *testing.T) {
|
||||
t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPICommandNameTooLong(t *testing.T) {
|
||||
// Непомещающееся целевое имя (layout.ErrNameTooLong) → 409 (штатно ушло в
|
||||
// review), не 500: это конфликт, требующий разбора, а не сбой сервера.
|
||||
// Наружу — нейтральное сообщение, сырой текст ошибки остаётся в логах.
|
||||
cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrNameTooLong)}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", resp.StatusCode)
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if msg, _ := got["error"].(string); !strings.Contains(msg, "слишком длинное") {
|
||||
t.Errorf("error = %q, want содержащее «слишком длинное»", msg)
|
||||
}
|
||||
}
|
||||
|
||||
+94
-26
@@ -18,27 +18,43 @@ type LiveStatus interface {
|
||||
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 — заглушка на случай, когда источник телеметрии не подключён
|
||||
// (Deps.Live == nil): живых данных нет, UI деградирует штатно.
|
||||
type noLive struct{}
|
||||
|
||||
func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }
|
||||
|
||||
// progressView — живой прогресс активной загрузки (для карточки и фрагмента
|
||||
// /progress). Active управляется store-состоянием (downloading), а не qbt:
|
||||
// когда задача покидает downloading, фрагмент возвращается без поллинга.
|
||||
// progressView — живой прогресс активной загрузки (вложенный блок карточки).
|
||||
// Active управляется store-состоянием (downloading), а не qbt: вне downloading
|
||||
// скорость и ETA смысла не имеют, и блок не рисуется. Своего опроса блок не
|
||||
// ведёт — цифры приезжают с тиком карточки (web-ui, «Самообновление живой
|
||||
// задачи»).
|
||||
type progressView struct {
|
||||
ID string
|
||||
Active bool // store-состояние downloading → показываем бар и поллим
|
||||
Active bool // store-состояние downloading → показываем бар
|
||||
Has bool // есть данные снимка
|
||||
Percent int
|
||||
DlSpeed string
|
||||
ETA string
|
||||
}
|
||||
|
||||
// seedingView — живая статистика раздачи (для страницы и фрагмента /seeding).
|
||||
// seedingView — живая статистика раздачи (секция страницы загрузки).
|
||||
// Has истинно только если торрент сидирует и данные есть — иначе секция
|
||||
// деградирует (пустой контейнер, поллинг прекращается).
|
||||
// деградирует (пустой контейнер). Своего опроса секция не ведёт: она лежит
|
||||
// внутри свопаемой области страницы, и её цифры приезжают с тиком страницы.
|
||||
type seedingView struct {
|
||||
ID string
|
||||
Has bool
|
||||
@@ -79,7 +95,13 @@ func buildSeeding(id string, l worker.Live, ok bool) seedingView {
|
||||
return v
|
||||
}
|
||||
|
||||
// handleFragProgress отдаёт партиал живого прогресса карточки (htmx-поллинг).
|
||||
// handleFragProgress отдаёт партиал живого прогресса карточки.
|
||||
//
|
||||
// Потребителя в новой разметке у маршрута нет: блок прогресса едет с тиком
|
||||
// карточки. Маршрут оставлен гасителем вкладок, отрисованных прошлой версией:
|
||||
// htmx не свопит 4xx/5xx и не снимает hx-trigger, поэтому удалённый маршрут
|
||||
// заставил бы старую вкладку стучать бесконечно, а партиал без поллинга гасит
|
||||
// её первым же тиком. Убирается отдельной уборкой после деплоя.
|
||||
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
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)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
s.fragTickErr(w, err, id, "dl-live-"+id)
|
||||
return
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
// handleFragCard отдаёт карточку списка целиком (htmx-самополлинг catched):
|
||||
// пока загрузка в catched, карточка опрашивает себя и по переходе в downloading
|
||||
// приносит обновлённый бейдж/имя и прогресс-поллер; выйдя из catched, свежая
|
||||
// карточка уже не несёт самополлинга — цикл завершается сам.
|
||||
// handleFragCard отдаёт карточку списка целиком — это тик её самообновления.
|
||||
// Пока задача наблюдаема (State.IsObservable), карточка опрашивает себя и на
|
||||
// каждом тике приносит текущее состояние целиком: бейдж, заголовок, набор
|
||||
// действий и живые цифры. Перестала быть наблюдаемой — свежая карточка уже не
|
||||
// несёт самополлинга, и цикл завершается сам.
|
||||
func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
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)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
s.fragTickErr(w, err, id, "card-"+id)
|
||||
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) {
|
||||
id, err := pathID(r)
|
||||
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)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
s.fragTickErr(w, err, id, "seeding-"+id)
|
||||
return
|
||||
}
|
||||
l, ok := s.liveFor(*d)
|
||||
s.render(w, "seeding", buildSeeding(id, l, ok))
|
||||
}
|
||||
|
||||
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound →
|
||||
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе).
|
||||
func (s *server) fragErr(w http.ResponseWriter, err error, id string) {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "не найдено", http.StatusNotFound)
|
||||
return
|
||||
// fragTickErr — отказ чтения на повторяющемся тике самообновления: 200 и
|
||||
// фрагмент, который объясняет положение дел и НЕ несёт самообновления.
|
||||
//
|
||||
// Статусом ошибки отвечать нельзя: htmx не свопит DOM на 4xx/5xx, поэтому
|
||||
// поверхность осталась бы прежней навсегда (человек не отличит «ничего не
|
||||
// изменилось» от «сервер не отвечает»), а её опрос продолжался бы бесконечно —
|
||||
// при затяжном отказе хранилища это поток записей в журнал с каждой открытой
|
||||
// вкладки. Фрагмент без 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)
|
||||
} else {
|
||||
s.deps.Logger.Warn("live fragment", "download_id", id, "error", err)
|
||||
}
|
||||
text = "не удалось обновить — обновите страницу"
|
||||
}
|
||||
s.deps.Logger.Error("live fragment", "download_id", id, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
s.render(w, "frag_note", fragNoteView{RootID: rootID, Text: text})
|
||||
}
|
||||
|
||||
// fragNoteView — самозавершающийся фрагмент отказа (см. fragNote). RootID —
|
||||
// id узла, который фрагмент собой заменяет.
|
||||
type fragNoteView struct {
|
||||
RootID string
|
||||
Text string
|
||||
}
|
||||
|
||||
// --- форматирование телеметрии ---
|
||||
|
||||
+246
-17
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -9,8 +10,8 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
// TestFragProgressDownloading: активная задача → фрагмент с прогрессом,
|
||||
// значениями снимка и атрибутами htmx-поллинга.
|
||||
// TestFragProgressDownloading: маршрут прогресса остался гасителем старых
|
||||
// вкладок — отдаёт цифры снимка и НЕ несёт собственного опроса.
|
||||
func TestFragProgressDownloading(t *testing.T) {
|
||||
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}}}
|
||||
@@ -21,25 +22,80 @@ func TestFragProgressDownloading(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
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) {
|
||||
t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "hx-trigger") {
|
||||
t.Errorf("партиал прогресса всё ещё опрашивает сервер сам:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading,
|
||||
// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается).
|
||||
func TestFragProgressStopsWhenNotDownloading(t *testing.T) {
|
||||
// TestProgressBlockHiddenOutsideDownloading: вне downloading блок живых цифр не
|
||||
// рисуется вовсе — скорость и ETA там смысла не имеют. Проверка на отсутствие
|
||||
// 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}
|
||||
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")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if body := rr.Body.String(); strings.Contains(body, "hx-trigger") {
|
||||
t.Errorf("завершённая задача всё ещё поллит:\n%s", body)
|
||||
body := rr.Body.String()
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
t.Errorf("фрагмент раздачи не содержит %q\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// Секция лежит внутри свопаемой области страницы — своего опроса не ведёт.
|
||||
if strings.Contains(body, "hx-trigger") {
|
||||
t.Errorf("секция раздачи всё ещё опрашивает сервер сама:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет.
|
||||
@@ -80,7 +140,8 @@ func TestFragSeedingDegrades(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже
|
||||
// в первом кадре (значения снимка) и атрибуты поллинга.
|
||||
// в первом кадре (значения снимка), а опрашивает себя сама карточка — один
|
||||
// поллер на поверхность, во вложенном блоке прогресса его нет.
|
||||
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}
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
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.
|
||||
func TestFragNotFound(t *testing.T) {
|
||||
// TestFragTickOnMissingDownload: тик по исчезнувшей задаче отвечает 200 и
|
||||
// фрагментом без hx-* — htmx не свопит 4xx/5xx, поэтому отказ статусом оставил
|
||||
// бы карточку прежней навсегда, а опрос — бесконечным.
|
||||
func TestFragTickOnMissingDownload(t *testing.T) {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,22 +15,46 @@ import (
|
||||
|
||||
// stubReader — минимальный Reader для проверки рендера списка.
|
||||
type stubReader struct {
|
||||
list []store.Download
|
||||
one *store.Download
|
||||
sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк)
|
||||
list []store.Download
|
||||
one *store.Download
|
||||
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) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
|
||||
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 {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
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) {
|
||||
if s.sizesErr != nil {
|
||||
return nil, s.sizesErr
|
||||
}
|
||||
return s.sizes, nil
|
||||
}
|
||||
|
||||
@@ -94,6 +118,16 @@ func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
||||
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 валидирует формат).
|
||||
const testULID = "01arz3ndektsv4rrffq69g5fav"
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ type reviewView struct {
|
||||
State string
|
||||
Error string // из ?err=
|
||||
StateError string // error_msg загрузки (напр. причина коллизии)
|
||||
PreviewError string // почему предпросмотр не построился, посчитано на показе
|
||||
MediaType string
|
||||
IsSeries bool
|
||||
Title string
|
||||
@@ -113,7 +114,11 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
|
||||
State: string(rd.Download.State),
|
||||
Error: errMsg,
|
||||
StateError: rd.Download.ErrorMsg.String,
|
||||
Hints: rd.Hints,
|
||||
// Причина пустого предпросмотра считается на показе и потому всегда про
|
||||
// текущий план; error_msg остался от последнего перехода и после смены
|
||||
// источника уже не про него.
|
||||
PreviewError: rd.PreviewError,
|
||||
Hints: rd.Hints,
|
||||
}
|
||||
if rec := rd.Recognition; rec != nil {
|
||||
view.MediaType = string(rd.Plan.Type)
|
||||
@@ -450,7 +455,7 @@ func (s *server) handleFragReview(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
s.fragTickErr(w, err, id, "review-main")
|
||||
return
|
||||
}
|
||||
s.render(w, "review_main", buildReviewView(id, rd, ""))
|
||||
|
||||
@@ -2,6 +2,9 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -85,3 +88,50 @@ func TestUIAddUrlencoded(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,7 +66,9 @@ type Request struct {
|
||||
Context string // подсказка для распознавания (опц.)
|
||||
}
|
||||
|
||||
// Result — итог приёма.
|
||||
// Result — итог приёма. При ненулевой ошибке Ingest возвращает НУЛЕВОЙ Result:
|
||||
// идентификатор загрузки, хеши, состояние и признак дедупликации не
|
||||
// публикуются (см. Ingest).
|
||||
type Result struct {
|
||||
DownloadID string
|
||||
Infohashes []string // все хеши источника (гибридный magnet: v1 и v2, v1 первым)
|
||||
@@ -78,7 +80,24 @@ type Result struct {
|
||||
// полей ссылки, дедуплицирует по активной задаче, иначе сохраняет загрузку в
|
||||
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
|
||||
// имени выполняет 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)
|
||||
if err != nil {
|
||||
// Невалидный источник — норма (адресат не команда, а пользователь, и он
|
||||
@@ -179,10 +198,10 @@ func (s *Service) parse(req Request) (parsedSource, error) {
|
||||
}
|
||||
// SourceRef — человекочитаемый референс (имя раздачи), НЕ адрес
|
||||
// добавления: torrent добавляется байтами (см. worker), не по SourceRef.
|
||||
// Фолбек на имя файла, если у раздачи нет содержательного имени
|
||||
// (пустое или NoName-сентинел "-").
|
||||
ref := strings.TrimSpace(info.DisplayName)
|
||||
if ref == "" || ref == "-" {
|
||||
// Фолбек на имя файла, если у раздачи нет содержательного имени:
|
||||
// вырожденное значение отбросил разборщик, здесь остаётся пустота.
|
||||
ref := info.DisplayName
|
||||
if ref == "" {
|
||||
ref = strings.TrimSpace(req.TorrentName)
|
||||
}
|
||||
return parsedSource{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -24,13 +25,22 @@ type fakeStore struct {
|
||||
upgradeID string // downloadID последнего вызова UpgradeCatchedMagnetToTorrent
|
||||
upgradeBlob []byte // байты, переданные в апгрейд
|
||||
upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent
|
||||
|
||||
lookupErr error // отказ хранилища на дедуп-чеке
|
||||
createErr error // отказ хранилища на заведении загрузки
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
|
||||
if f.lookupErr != nil {
|
||||
return nil, f.lookupErr
|
||||
}
|
||||
return f.active, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
return f.active, nil
|
||||
}
|
||||
@@ -300,3 +310,31 @@ func TestIngestRejectsNonMagnet(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,28 +134,40 @@ func TestIngestTorrentTooLarge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// У раздачи без имени source_ref берётся из имени файла (фолбек).
|
||||
// У раздачи без содержательного имени source_ref берётся из имени файла.
|
||||
// Случая два, и они разные: раздача БЕЗ поля name (BestName() == "") и
|
||||
// раздача, объявившая вырожденное `-` (metainfo.NoName) — второй нормализует
|
||||
// разборщик, приём про него уже не знает.
|
||||
func TestIngestTorrentNameFallback(t *testing.T) {
|
||||
// Info без name → BestName() == "" → фолбек на TorrentName.
|
||||
info := metainfo.Info{Name: "", Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
|
||||
infoBytes, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
|
||||
var buf bytes.Buffer
|
||||
if err := mi.Write(&buf); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
|
||||
var buf bytes.Buffer
|
||||
if err := mi.Write(&buf); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
fs := &fakeStore{}
|
||||
_, err = newService(fs).Ingest(context.Background(),
|
||||
Request{TorrentData: buf.Bytes(), TorrentName: "Fallback.Name.torrent"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" {
|
||||
t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef)
|
||||
fs := &fakeStore{}
|
||||
_, err = newService(fs).Ingest(context.Background(),
|
||||
Request{TorrentData: buf.Bytes(), TorrentName: "Fallback.Name.torrent"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" {
|
||||
t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,11 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
|
||||
if !underRoot(root, dst) {
|
||||
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})
|
||||
}
|
||||
if len(links) == 0 {
|
||||
@@ -267,6 +272,11 @@ type Result struct {
|
||||
// ErrCollision — цель существует и это другой файл (нужен review).
|
||||
var ErrCollision = errors.New("layout: target collision")
|
||||
|
||||
// ErrNameTooLong — компонент целевого пути длиннее предела длины имени
|
||||
// (maxComponentBytes). Проверяется в BuildLinks, до первой операции с ФС:
|
||||
// задача уходит в review с доменной причиной, а не в failed с текстом ядра.
|
||||
var ErrNameTooLong = errors.New("layout: имя не помещается")
|
||||
|
||||
// ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных
|
||||
// (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не
|
||||
// единственный файл (см. state-reconciliation, инвариант безопасного Undo).
|
||||
|
||||
@@ -6,6 +6,53 @@ import (
|
||||
"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 чистит один компонент пути (имя папки/файла): убирает
|
||||
// разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает
|
||||
// пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -52,12 +52,23 @@ func TestIntegration_TVMaze(t *testing.T) {
|
||||
// пропускается; включается ключом:
|
||||
//
|
||||
// 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) {
|
||||
key := os.Getenv("TVDB_API_KEY")
|
||||
if key == "" {
|
||||
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 {
|
||||
t.Fatalf("NewTVDB: %v", err)
|
||||
}
|
||||
@@ -73,11 +84,30 @@ func TestIntegration_TVDB(t *testing.T) {
|
||||
if i >= 5 {
|
||||
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 {
|
||||
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 := cands[0].ID
|
||||
|
||||
+114
-11
@@ -25,16 +25,37 @@ type TVDBConfig struct {
|
||||
Proxy string
|
||||
Timeout time.Duration
|
||||
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 и
|
||||
// кэшируется; при 401 выполняется повторный логин. Формы ответов сверены с
|
||||
// живым API v4 (см. integration_test.go).
|
||||
// живым API v4 (см. integration_test.go) — кроме блока переводов в выдаче
|
||||
// поиска: он взят из публичной документации и живым прогоном не подтверждён
|
||||
// (docs/research/tvdb-search-translations.md).
|
||||
type TVDB struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
hc *http.Client
|
||||
log *slog.Logger
|
||||
apiKey string
|
||||
baseURL string
|
||||
language string
|
||||
hc *http.Client
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
@@ -56,7 +77,13 @@ func NewTVDB(cfg TVDBConfig, logger *slog.Logger) (*TVDB, error) {
|
||||
if logger == nil {
|
||||
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" }
|
||||
@@ -148,10 +175,66 @@ type tvdbSearchResp struct {
|
||||
TVDBID string `json:"tvdb_id"`
|
||||
Name string `json:"name"`
|
||||
Year string `json:"year"`
|
||||
// Translations — карта «код языка → название». Тип сырой намеренно:
|
||||
// строгий тип дал бы косметическому полю право провалить json.Unmarshal
|
||||
// всего ответа и убить кандидатов, которые сейчас приезжают нормально.
|
||||
// Форма поля живым API не подтверждена (см. docs/research/).
|
||||
Translations json.RawMessage `json:"translations"`
|
||||
} `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 TVDB он фильтрует выдачу по
|
||||
// основному языку записи, а не выбирает перевод, и сузил бы результат ровно на
|
||||
// иноязычных записях. Локаль работает только на разборе ответа
|
||||
// (openspec/specs/metadata-match, docs/research/tvdb-search-translations.md).
|
||||
func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||
typ := "series"
|
||||
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)
|
||||
}
|
||||
out := make([]Candidate, 0, len(resp.Data))
|
||||
sawLangKeys := false
|
||||
for _, r := range resp.Data {
|
||||
if r.TVDBID == "" {
|
||||
continue
|
||||
}
|
||||
year, _ := strconv.Atoi(r.Year)
|
||||
title, sawKeys := translatedName(r.Translations, t.language)
|
||||
sawLangKeys = sawLangKeys || sawKeys
|
||||
if title == "" {
|
||||
title = r.Name // фолбэк тотален: перевода нет, он пуст или блок негоден
|
||||
}
|
||||
out = append(out, Candidate{
|
||||
Provider: "tvdb",
|
||||
ID: r.TVDBID,
|
||||
Title: r.Name,
|
||||
Year: year,
|
||||
URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID,
|
||||
Provider: "tvdb",
|
||||
ID: r.TVDBID,
|
||||
Title: title,
|
||||
OriginalTitle: r.Name,
|
||||
Year: year,
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"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" {
|
||||
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) {
|
||||
_, _ = 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 {
|
||||
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 {
|
||||
t.Fatalf("NewTVDB: %v", err)
|
||||
}
|
||||
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) {
|
||||
var logins atomic.Int32
|
||||
srv := fakeTVDB(t, &logins)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"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())
|
||||
}
|
||||
|
||||
// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы.
|
||||
// sanitize убирает управляющие и невидимые символы, переводы строк, схлопывает
|
||||
// пробелы. Категорию Cf (zero-width, BOM, RLO) снимаем наравне с C0: значения
|
||||
// приходят из метабазы и из сохранённого контекста, то есть снаружи, а ярлык
|
||||
// уезжает в карточку Telegram, в шапку веб-UI и в имя раздачи qBittorrent —
|
||||
// RLO там переворачивает отображение. Это же обещает требование metadata-match:
|
||||
// режиссёр в плане не чистится именно потому, что его чистит рендер.
|
||||
func sanitize(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\t' || r == '\r' {
|
||||
return ' '
|
||||
}
|
||||
if r < 0x20 {
|
||||
if r < 0x20 || unicode.Is(unicode.Cf, r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
|
||||
@@ -158,3 +158,25 @@ func TestDeriveNameNilProviderUsesFallback(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package qbt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
)
|
||||
|
||||
// fakeQBittorrent — минимальный стенд WebUI API: требует cookie SID, выдаёт
|
||||
@@ -238,3 +241,102 @@ func TestLoginFailure(t *testing.T) {
|
||||
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 утёк в сообщение записи")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,13 +72,17 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
|
||||
}
|
||||
|
||||
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
||||
// Названия чистим на КОПИИ: выбор кандидата человеком — такой же
|
||||
// путь подтверждения матча, как авто, и закреплённое название так
|
||||
// же уезжает в имя каталога. Исходный cands не трогаем — по нему
|
||||
// ниже ищется сильный матч, и сдвигать его гейт задача не заказывала.
|
||||
for _, c := range cands {
|
||||
ck := c.Provider + ":" + c.ID
|
||||
if seen[ck] || len(candidates) >= maxCandidates {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
// Каноническое название чистим здесь — в единственной точке сборки 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{
|
||||
Provider: prov,
|
||||
ProviderID: pid,
|
||||
Title: c.Title,
|
||||
Title: title,
|
||||
Year: c.Year,
|
||||
Director: directorOf(ctx, r.log, p, mt, c.ID),
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
// Реальный кейс: русское релиз-имя, матч по оригинальному названию.
|
||||
// При сильном матче по первому ключу дальнейшие запросы не делаются.
|
||||
|
||||
@@ -260,7 +260,13 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||
// review, когда единичного сильного матча нет.
|
||||
match, candidates := r.matchMetadata(ctx, plan)
|
||||
if match != nil {
|
||||
plan.Title = match.Title
|
||||
// Match.Title пришёл санитизированным (buildMatch чистит его в единственной
|
||||
// точке сборки матча). Здесь остаётся только вопрос пригодности: название,
|
||||
// не годное как имя каталога, не подставляем вовсе — в плане остаётся
|
||||
// название распознавания, а decide уводит раздачу в review.
|
||||
if UsableTitle(match.Title) {
|
||||
plan.Title = match.Title
|
||||
}
|
||||
if match.Year != 0 {
|
||||
plan.Year = match.Year
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
)
|
||||
|
||||
// homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских
|
||||
@@ -34,17 +36,49 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
|
||||
// SanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
|
||||
// (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и
|
||||
// обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем
|
||||
// до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов.
|
||||
func sanitizeTitle(s string) string {
|
||||
func SanitizeTitle(s string) string {
|
||||
s = stripControl(s)
|
||||
s = collapseSpaces(s)
|
||||
s = foldHomoglyphs(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
|
||||
// НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и
|
||||
// homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review),
|
||||
@@ -57,7 +91,7 @@ func sanitizePlan(p *Plan, log *slog.Logger) {
|
||||
}
|
||||
|
||||
func sanitizeField(v, field string, log *slog.Logger) string {
|
||||
clean := sanitizeTitle(v)
|
||||
clean := SanitizeTitle(v)
|
||||
if clean != v && log != nil {
|
||||
log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean)
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ func TestSanitizeTitle(t *testing.T) {
|
||||
"The Matrix": "The Matrix",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeTitle(in); got != want {
|
||||
t.Errorf("sanitizeTitle(%q) = %q, want %q", in, got, want)
|
||||
if got := SanitizeTitle(in); got != want {
|
||||
t.Errorf("SanitizeTitle(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,10 +84,10 @@ func validateSchema(p *Plan, in Input) error {
|
||||
}
|
||||
|
||||
// decide считает решение модели уверенности (см. recognition.md). Авто —
|
||||
// только если выполнено всё: подтверждённый единичный матч в базе; чистая
|
||||
// структурная валидация (для сериала — число серий бьётся с базой);
|
||||
// согласованность с пред-парсом; самооценка LLM не ниже порога. Любая
|
||||
// невыполненная — причина ухода в review.
|
||||
// только если выполнено всё: подтверждённый единичный матч в базе; каноническое
|
||||
// название матча пригодно как имя каталога; чистая структурная валидация (для
|
||||
// сериала — число серий бьётся с базой); согласованность с пред-парсом;
|
||||
// самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review.
|
||||
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision {
|
||||
var reasons []string
|
||||
|
||||
@@ -98,6 +98,16 @@ func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold
|
||||
reasons = append(reasons, "не найдено в базе или несколько кандидатов")
|
||||
}
|
||||
|
||||
// Каноническое название базы, непригодное как имя каталога (пустое или без
|
||||
// единой буквы и цифры), в план не подставлено — там осталось название
|
||||
// распознавания. Матч при этом верен: id, год и режиссёр на месте, поэтому
|
||||
// отклоняем не матч, а авто-раскладку. Match.Title уже санитизирован
|
||||
// (buildMatch), второй раз не чистим: два независимых пересчёта одного
|
||||
// условия разъедутся на первой же правке одного из них.
|
||||
if match != nil && !UsableTitle(match.Title) {
|
||||
reasons = append(reasons, "название из базы непригодно как имя каталога")
|
||||
}
|
||||
|
||||
reasons = append(reasons, structuralWarnings(p)...)
|
||||
|
||||
if match != nil && p.Type == MediaSeries {
|
||||
|
||||
@@ -64,6 +64,43 @@ func (s State) IsTerminal() bool {
|
||||
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 — декларативный граф легальных переходов машины состояний
|
||||
// (from → множество допустимых to). Единственный источник истины о легальности
|
||||
// рёбер: покрывает все переходы, которые worker выполняет по всем capability
|
||||
|
||||
@@ -653,3 +653,52 @@ func TestListAndByState(t *testing.T) {
|
||||
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
@@ -89,6 +89,41 @@ func listWhere(f ListFilter) (string, []any) {
|
||||
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 возвращает страницу загрузок под фильтром и общее число
|
||||
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
|
||||
// добавления в источник (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)
|
||||
}
|
||||
|
||||
q := `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` + where +
|
||||
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
|
||||
q := listSelect + where + listOrder + `
|
||||
LIMIT ? OFFSET ?`
|
||||
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)
|
||||
|
||||
|
||||
@@ -333,3 +333,39 @@ func TestCreateDownloadPersistsDisplayName(t *testing.T) {
|
||||
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
@@ -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) {
|
||||
res, err := b.ingestor.Ingest(ctx, req)
|
||||
if err != nil {
|
||||
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
|
||||
// при раннем разборе источника id ещё нет — даём дружелюбный текст без
|
||||
// него (детали всё равно в логах на доменной границе).
|
||||
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||
// Приём на любом пути ошибки возвращает нулевой Result (контракт
|
||||
// ingest.Ingest) — идентификатора загрузки нет. Корреляционного ключа
|
||||
// у отказа приёма в Telegram нет вовсе (request_id — понятие
|
||||
// HTTP-границы): текст остаётся дружелюбным без ключа, детали — в
|
||||
// записи приёма на доменной границе (capability=ingest, infohash).
|
||||
b.send(chatID, opErr("Не удалось принять загрузку", ""), nil)
|
||||
return
|
||||
}
|
||||
if res.Deduplicated {
|
||||
@@ -398,6 +400,15 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
|
||||
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) {
|
||||
// Коллизия цели: задача штатно ушла в review с причиной — показываем
|
||||
// конкретно и обновляем карточку (в ней теперь причина коллизии).
|
||||
|
||||
@@ -3,6 +3,8 @@ package tgbot
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -55,10 +57,15 @@ func (f *fakeAPI) GetFileDirectURL(string) (string, error)
|
||||
type fakeIngestor struct {
|
||||
lastReq ingest.Request
|
||||
res ingest.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) {
|
||||
f.lastReq = req
|
||||
if f.err != nil {
|
||||
// Контракт приёма: на ошибке результат нулевой (ingest.Ingest).
|
||||
return ingest.Result{}, f.err
|
||||
}
|
||||
return f.res, nil
|
||||
}
|
||||
|
||||
@@ -72,6 +79,7 @@ type fakeReviewer struct {
|
||||
deleted []string
|
||||
dismissed []string
|
||||
chosen map[string]string // downloadID → выбранный candidateID
|
||||
applyErr error // чем отвечает Apply (nil — успехом)
|
||||
}
|
||||
|
||||
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 {
|
||||
f.applied = append(f.applied, id)
|
||||
return nil
|
||||
return f.applyErr
|
||||
}
|
||||
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
|
||||
if f.refined == nil {
|
||||
@@ -585,3 +593,46 @@ func TestBot_CallbackStaleButton(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,22 +48,20 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
case store.StateLinking:
|
||||
return "⏳ Раскладываю #" + idCode(id) + "…", nil
|
||||
case store.StateDone:
|
||||
return b.renderDone(rd), b.deletableKeyboard(id)
|
||||
return b.renderDone(rd), b.stateKeyboard(id, state)
|
||||
default:
|
||||
// state — внутренний enum состояния (не внешний ввод), экранировать не нужно.
|
||||
text := fmt.Sprintf("Задача #%s — %s.", idCode(id), state)
|
||||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||||
text += "\n" + esc(msg)
|
||||
}
|
||||
switch state {
|
||||
case store.StateFailed, store.StateStuck:
|
||||
if state == store.StateFailed || state == store.StateStuck {
|
||||
// failed/stuck — даём кнопку повтора.
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
id := rd.Download.ID
|
||||
sid := id
|
||||
@@ -277,6 +290,18 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
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 — клавиатура состояний, откуда доступно полное удаление
|
||||
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь
|
||||
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -33,7 +33,7 @@ type File struct {
|
||||
type Info struct {
|
||||
Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
|
||||
Infohashes []string // все хеши файла (гибрид несёт v1 и v2); v1 раньше v2
|
||||
DisplayName string // имя раздачи (info.name)
|
||||
DisplayName string // имя раздачи (info.name), нормализованное displayName; пусто, если имени нет
|
||||
Files []File // файлы раздачи (для одиночного — один элемент)
|
||||
TotalLength int64 // суммарный размер
|
||||
Trackers []string // announce + announce-list (плоско, без дублей)
|
||||
@@ -69,7 +69,7 @@ func Parse(data []byte) (Info, error) {
|
||||
return Info{
|
||||
Infohash: hashes[0],
|
||||
Infohashes: hashes,
|
||||
DisplayName: meta.BestName(),
|
||||
DisplayName: displayName(meta.BestName()),
|
||||
Files: fs,
|
||||
TotalLength: total,
|
||||
Trackers: mi.UpvertedAnnounceList().DistinctValues(),
|
||||
@@ -77,6 +77,33 @@ func Parse(data []byte) (Info, error) {
|
||||
}, 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 (одиночный файл или
|
||||
// дерево). Байты .torrent — недоверенный вход: на кривых/вырожденных раздачах
|
||||
// библиотека (UpvertedFiles/FileTree) способна паниковать (напр. делением на
|
||||
@@ -111,8 +138,11 @@ var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.
|
||||
func (i Info) Context() string {
|
||||
var lines []string
|
||||
|
||||
if name := strings.TrimSpace(i.DisplayName); name != "" {
|
||||
lines = append(lines, name)
|
||||
// Имя уже нормализовано разборщиком (displayName): вырожденное значение и
|
||||
// разделители строк сюда не доходят. Проверка на пустоту остаётся — имени
|
||||
// у раздачи может не быть вовсе, и тогда строки названия просто нет.
|
||||
if i.DisplayName != "" {
|
||||
lines = append(lines, i.DisplayName)
|
||||
}
|
||||
if i.TotalLength > 0 {
|
||||
lines = append(lines, "Размер: "+humanSize(i.TotalLength))
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -217,12 +218,48 @@ func TestParseInvalidBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextEmptyWhenNoFields(t *testing.T) {
|
||||
// info без имени (NoName-сентинел), без трекера/комментария: строк-фактов
|
||||
// минимум. Проверяем, что Context не паникует и не тянет сеть (чистая
|
||||
// функция — сетевых вызовов в коде нет по построению).
|
||||
// Раздача объявила вырожденное имя `-` (metainfo.NoName — конвенция «имени
|
||||
// нет», которую раздача выставляет САМА; библиотека его не синтезирует).
|
||||
// Разборщик нормализует его к пустой строке, поэтому строки названия в
|
||||
// контексте нет. Трекера и комментария тоже нет — строк-фактов минимум.
|
||||
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{
|
||||
Name: name,
|
||||
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 пусто (вырожденное имя)", 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{
|
||||
Name: "-", // metainfo.NoName
|
||||
Length: 0,
|
||||
PieceLength: 1024,
|
||||
Pieces: pieces(1),
|
||||
@@ -231,5 +268,50 @@ func TestContextEmptyWhenNoFields(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
_ = info.Context() // не паникует; состав строк — по полям
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,15 @@ type fakeNamer struct {
|
||||
name string
|
||||
fields naming.Fields // извлечённая структура (ok=true, если Title непуст)
|
||||
gotContext string
|
||||
gotHint string // подсказка имени — третий потребитель torrent.Info.DisplayName
|
||||
calls int
|
||||
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.gotContext = contextText
|
||||
f.gotHint = hint
|
||||
if f.onCall != nil {
|
||||
f.onCall()
|
||||
}
|
||||
|
||||
+85
-21
@@ -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))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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.
|
||||
@@ -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 {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
// Возврат в активную обработку — только через атомарный гард инварианта
|
||||
// «не более одной активной задачи на infohash» (см. design ulid-identity, D4).
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
logctx.From(ctx).Info("review re-recognizing without hint")
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
return nil
|
||||
@@ -462,10 +476,13 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error)
|
||||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
||||
return fmt.Errorf("refine: %w", err)
|
||||
}
|
||||
@@ -611,9 +628,7 @@ func (w *Worker) Delete(ctx context.Context, id string) (err error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
switch d.State {
|
||||
case store.StateDone, store.StateOrphaned, store.StateTargetMissing:
|
||||
default:
|
||||
if !d.State.CanDelete() {
|
||||
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())
|
||||
@@ -688,6 +703,10 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) (e
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
|
||||
// вызывающего, capability identity): иначе запись клиента об отказе уходит
|
||||
// без download_id/infohash.
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
|
||||
// вызывающего, capability identity): иначе запись клиента об отказе уходит
|
||||
// без download_id/infohash.
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
// title_pinned=false означает, что название кандидата не годится как имя
|
||||
// каталога и в плане осталось название распознавания. Без этого атрибута
|
||||
// вопрос «почему папка названа догадкой, а не как в базе» по логам не
|
||||
// разбирается: на авто-пути такой отказ несёт причина решения, здесь её нет.
|
||||
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 и в ярлык
|
||||
// раздачи (best-effort, косметика). Сбой обновления имени не должен ронять
|
||||
// выбор кандидата: логируем и продолжаем.
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
@@ -835,6 +864,10 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
|
||||
// вызывающего, capability identity): иначе запись клиента об отказе уходит
|
||||
// без download_id/infohash.
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
// Режиссёр вручную заданного источника из метабазы (credits) — best-effort
|
||||
// косметика: сбой чтения рекогниции не валит смену источника (тип по умолчанию
|
||||
// movie, как трактует candMediaType(nil)).
|
||||
@@ -842,7 +875,7 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
|
||||
if w.recognizer != nil {
|
||||
rec, rerr := w.store.GetCurrentRecognition(ctx, id)
|
||||
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)
|
||||
rec = nil
|
||||
}
|
||||
@@ -890,6 +923,18 @@ func sourcePins(provider, providerID, title string, year int, director string) m
|
||||
if year > 0 {
|
||||
yr = strconv.Itoa(year)
|
||||
}
|
||||
// Название источника — недоверенное значение метабазы, и чистится оно здесь,
|
||||
// на единственном общем доме набора пинов: через sourcePins идут и превью
|
||||
// источника, и его закрепление, поэтому «превью = применение» держится
|
||||
// конструкцией, а не памятью. Чистка идемпотентна — на кандидате, записанном
|
||||
// уже с чисткой, она ничего не меняет, а строку из БД, сохранённую прежней
|
||||
// версией, приводит в порядок. Непригодное как имя каталога название пином не
|
||||
// становится: пустое значение очищает пин, и в плане остаётся название
|
||||
// распознавания.
|
||||
title = recognize.SanitizeTitle(title)
|
||||
if !recognize.UsableTitle(title) {
|
||||
title = ""
|
||||
}
|
||||
return map[string]string{
|
||||
ovrProvider: provider,
|
||||
ovrProviderID: providerID,
|
||||
@@ -914,14 +959,21 @@ func candMediaType(rec *store.Recognition) recognize.MediaType {
|
||||
type ReviewData struct {
|
||||
Download store.Download
|
||||
Recognition *store.Recognition
|
||||
Plan recognize.Plan // эффективный (с применёнными правками)
|
||||
Preview []layout.Link // целевые пути активного источника (Src — относительный)
|
||||
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
|
||||
Provider string // эффективный провайдер (с учётом выбора)
|
||||
ProviderID string // эффективный id в базе
|
||||
Hints []string
|
||||
Overrides map[string]string
|
||||
Plan recognize.Plan // эффективный (с применёнными правками)
|
||||
Preview []layout.Link // целевые пути активного источника (Src — относительный)
|
||||
// PreviewError — почему предпросмотр не построился, человекочитаемо (пусто —
|
||||
// построился либо строить было нечего). Считается НА ПОКАЗЕ, поэтому всегда
|
||||
// относится к текущему эффективному плану — в отличие от error_msg загрузки,
|
||||
// который остался от последнего перехода и после смены источника устаревает.
|
||||
// Задача в review без записанной причины (обычный «нет матча») иначе оставила
|
||||
// бы экран без объяснения, почему пропала кнопка «Применить».
|
||||
PreviewError string
|
||||
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
|
||||
Provider string // эффективный провайдер (с учётом выбора)
|
||||
ProviderID string // эффективный id в базе
|
||||
Hints []string
|
||||
Overrides map[string]string
|
||||
}
|
||||
|
||||
// MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий
|
||||
@@ -1043,8 +1095,12 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
|
||||
rd.Preview = links
|
||||
} else {
|
||||
// Видимая деградация: без превью на экране ревью пропадает
|
||||
// кнопка «Применить» — не рядовой Debug, а WARN.
|
||||
// кнопка «Применить» — не рядовой Debug, а WARN. Причину
|
||||
// отдаём транспорту: молча пропавшая кнопка неотличима от
|
||||
// поломки, а у задачи без записанной причины взять её больше
|
||||
// неоткуда.
|
||||
log.Warn("review data build preview failed", "error", lerr)
|
||||
rd.PreviewError = lerr.Error()
|
||||
}
|
||||
}
|
||||
// Единый список источников: нейронка + кандидаты, каждый с
|
||||
@@ -1228,6 +1284,7 @@ const (
|
||||
reasonBuild = "build" // не удалось построить план ссылок
|
||||
reasonPersist = "persist" // ссылки на диске, но учёт не записан
|
||||
reasonCollision = "collision" // целевой путь уже занят (layout.ErrCollision)
|
||||
reasonNameTooLong = "name_too_long" // компонент целевого пути длиннее предела (layout.ErrNameTooLong)
|
||||
reasonTitleFolderDesync = "title_folder_desync" // ≥2 разных живых папок тайтла с одним матчем
|
||||
)
|
||||
|
||||
@@ -1276,8 +1333,15 @@ func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, pr
|
||||
// applyOverrides применяет ручные правки к плану: каноническое имя/год (из
|
||||
// выбранного кандидата базы) и помечает игнорируемые файлы ролью ignore (их
|
||||
// раскладка пропустит).
|
||||
//
|
||||
// Название чистится здесь, НА ЧТЕНИИ, и это не дубль чистки в sourcePins: та
|
||||
// держит хранилище чистым, а эта защищает от значений, записанных прежними
|
||||
// версиями. Пин, закреплённый до появления санитайзинга, иначе доезжает до имени
|
||||
// каталога дословно при обычном «Применить» — без повторного выбора источника
|
||||
// запись в хранилище никто не перепишет. Чистка идемпотентна, так что на пинах,
|
||||
// записанных уже с ней, обе не делают ничего.
|
||||
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
|
||||
}
|
||||
if y := overrides[ovrYear]; y != "" {
|
||||
|
||||
@@ -2285,3 +2285,263 @@ func TestSweepLinking_LeavesOtherStates(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// Третий потребитель 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
@@ -961,7 +961,8 @@ func (w *Worker) logCmd(ctx context.Context, cmd, id string, err error) {
|
||||
log := logctx.FromOr(ctx, w.log)
|
||||
switch {
|
||||
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)
|
||||
default:
|
||||
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 {
|
||||
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 — перецепляемся к ней,
|
||||
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add — когда
|
||||
// источника в qBittorrent нет. Базис таймаута сбрасывается ниже через
|
||||
@@ -1076,8 +1083,7 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
// Байты torrent недоступны — откатываем активацию, задача не должна
|
||||
// «качаться» без раздачи в qBittorrent.
|
||||
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
||||
w.log.Error("retry rollback failed",
|
||||
"capability", capReview, "download_id", id, "error", rbErr)
|
||||
logctx.From(ctx).Error("retry rollback failed", "error", rbErr)
|
||||
}
|
||||
return fmt.Errorf("retry: prepare add: %w", prepErr)
|
||||
}
|
||||
@@ -1085,8 +1091,7 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
// Активация уже прошла — откатываем задачу в прежнее состояние,
|
||||
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
|
||||
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
||||
w.log.Error("retry rollback failed",
|
||||
"capability", capReview, "download_id", id, "error", rbErr)
|
||||
logctx.From(ctx).Error("retry rollback failed", "error", rbErr)
|
||||
}
|
||||
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: сбой лишь лишает свежего окна
|
||||
// (WARN), сам retry уже состоялся.
|
||||
if err := w.store.SetRetriedAt(ctx, id, w.now()); err != nil {
|
||||
w.log.Warn("retry basis reset failed",
|
||||
"capability", capReview, "download_id", id, "error", err)
|
||||
logctx.From(ctx).Warn("retry basis reset failed", "error", err)
|
||||
}
|
||||
// Сброс счётчика пропусков источника: retried source_gone-задача иначе вошла бы
|
||||
// в downloading с source_miss_count == threshold и упала бы снова на ближайшем
|
||||
@@ -1105,11 +1109,10 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
// обещанного грейс-окна (MAJOR-3). Best-effort: сбой лишь лишает свежего окна.
|
||||
if d.SourceMissCount != 0 {
|
||||
if err := w.store.SetSourceMissCount(ctx, id, 0); err != nil {
|
||||
w.log.Warn("retry miss count reset failed",
|
||||
"capability", capReview, "download_id", id, "error", err)
|
||||
logctx.From(ctx).Warn("retry miss count reset failed", "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)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ type fakeStore struct {
|
||||
transitions []transition
|
||||
torrents map[string][]byte // download_id → байты .torrent
|
||||
promoteErr error // если задан — PromoteCatched возвращает его, НЕ меняя state (симуляция транзиентного сбоя БД)
|
||||
setStateErr error // если задан — SetDownloadState отказывает (ветка отката Retry)
|
||||
retriedErr error // если задан — SetRetriedAt отказывает (best-effort сброс базиса)
|
||||
missCntErr error // если задан — SetSourceMissCount отказывает (best-effort сброс счётчика)
|
||||
}
|
||||
|
||||
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 {
|
||||
if f.setStateErr != nil {
|
||||
return f.setStateErr
|
||||
}
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
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 {
|
||||
if f.missCntErr != nil {
|
||||
return f.missCntErr
|
||||
}
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
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 {
|
||||
if f.retriedErr != nil {
|
||||
return f.retriedErr
|
||||
}
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
@@ -283,6 +295,7 @@ type fakeQbt struct {
|
||||
torrentsErr error
|
||||
onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
|
||||
added []qbt.AddRequest
|
||||
addCtx []context.Context // ctx каждого вызова Add (проверка scoped-логгера)
|
||||
addErr error
|
||||
onAdd func() // вклинивается в момент Add (симуляция отмены в окне после add)
|
||||
files []qbt.File
|
||||
@@ -321,7 +334,8 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
|
||||
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 {
|
||||
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, D1–D3, D6–D8) |
|
||||
| `review-ops` | отработал, 3 находки + ответы на обязательные вопросы |
|
||||
|
||||
Состав запущенного сверен с профилем `standard` — расхождений нет.
|
||||
|
||||
**Счёт находок:** на входе 13 (S1–S4, C2, A1, A2, A3adv, A4, A5, O1–O3; 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:520–556` (уборка
|
||||
по условию `after.State != store.StateCatched`, безразлична к команде),
|
||||
`internal/httpapi/review.go:384–391` (маршрут `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:244–268` — штатный тест уборки ставит
|
||||
`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:495–556` (гард
|
||||
«подтверждённое отсутствие» проверяет отсутствие **торрента**, не отсутствие
|
||||
**данных**)
|
||||
- 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` — новый батч), `:557–572`
|
||||
(`Undo` — только `LatestBatchID`), `:625–641` (`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:985–991, 1016–1022` — проверено чтением: `Cancel`
|
||||
логирует только `from`/`to`, `Dismiss` — `from`/`to`/`code` **нового**
|
||||
перехода; контракт поля `code` — `docs/conventions/logging.md:26–30`.
|
||||
- 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:452–461`,
|
||||
`internal/tgbot/render.go:44–50, 281–290` — проверено чтением: уведомление
|
||||
`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:95–98` — проверено чтением,
|
||||
комментарий дословно: «без JS гейт — только раскрытие details и явный submit
|
||||
(диалога нет)»; `internal/tgbot/render.go:266–290` — «Закрыть» в общем ряду
|
||||
кнопок. `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:495–509`)
|
||||
проверяет отсутствие торрента в qBittorrent, но не отсутствие данных на
|
||||
save_path; `qbt.Add` подхватывает лежащие там файлы, уборка бьёт
|
||||
`torrents/delete` с `deleteFiles=true`. Путь: пользователь снял раздачу,
|
||||
файлы оставив (это предписывает сам `download-tracking:169–174` как
|
||||
восстановление) → подал тот же торрент → отменил в окне листинг+add (сотни
|
||||
мс — секунды). Инвариант «источник неприкосновенен»: исключение (2) покрывает
|
||||
«собственный торрент», но не пред-существующие данные; UI при этом обещает
|
||||
«файлы и раздача останутся нетронутыми» (`download_main.html:100–111`).
|
||||
Оракул: `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:557–572, 625–641`), каждый `Apply` создаёт новый
|
||||
(`review.go:332`): связка «закрыть `done` → relink» бросает ссылку батча 1
|
||||
навсегда, а «полное удаление» после этого не освобождает место (инод жив за
|
||||
счёт брошенной ссылки). Оракул: `TestDismissRelinkStrandsLibraryLinks`,
|
||||
`TestStrandedLinkDefeatsDelete` — прогнаны, воспроизводят. Провенанс:
|
||||
`review-adversary` A2.
|
||||
- **У-3 (minor, наблюдаемость).** Лог-строки переходов `Cancel`/`Dismiss` не
|
||||
несут прежних `error_code`/`error_msg`, хотя `d` их держит в момент записи
|
||||
(`worker.go:985–991, 1016–1022`): «почему задача упала» после закрытия не
|
||||
восстановить одной строкой. Кандидат: `prev_error_code`/`prev_error_msg` в
|
||||
лог перехода. Оракул: чтение кода + контракт `logging.md:26–30`. Провенанс:
|
||||
`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:463–465`) несёт
|
||||
ту же формулировку о подтверждающем шаге с той же 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, решения D1–D3, 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)); «Типовые ложноположительные» и оба
|
||||
подраздела «Недоступно проверке» — присутствуют и использованы. Здесь
|
||||
пробелов нет.
|
||||
+170
@@ -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
Reference in New Issue
Block a user