Рефакторинг границ capabilities: цепочка загрузка→матч→ревью→раскладка (openspec)
Привёл набор capabilities в OpenSpec к цепочке обработки, чтобы имя capability отвечало одному поведению. Чисто по спекам, код и поведение системы не меняются. Change refactor-capability-boundaries (архивирован): - recognition разделён на recognition (разбор LLM) + metadata-match (сверка с базами) - review выделен из web-ui + мигрирован из docs/specs/review-ux.md - новые capability из docs/specs: file-layout, download-tracking, notifications - identity очищен до инфра-id; приём (инфохэши, дедуп, ядро приёма) — в ingest - уведомление о рассинхроне перенесено из state-reconciliation в notifications - дубль владения путём и безопасного undo оставлен в state-reconciliation Итог: 11 capabilities, openspec validate --strict проходит (+37/−11 требований). Источник истины по мигрированным темам переехал в openspec/specs (шапки в docs). Снят пункт беклога «Пересмотр набора capabilities». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-03
|
||||
@@ -0,0 +1,202 @@
|
||||
## Context
|
||||
|
||||
Набор capabilities в `openspec/specs/` сложился по ходу пилотной миграции и не
|
||||
отражает цепочку обработки загрузки. Три проблемы:
|
||||
|
||||
1. `recognition` смешивает разбор LLM и работу с метабазами.
|
||||
2. Поведение ревью размазано: часть — в `web-ui`, основная часть — в не
|
||||
перенесённом `docs/specs/review-ux.md`.
|
||||
3. Звенья цепочки `file-layout`, `download-tracking`, `notifications` в OpenSpec
|
||||
отсутствуют — источник истины по ним в `docs/specs/` (`jellyfin-layout.md`,
|
||||
`workflow.md`, `architecture.md`).
|
||||
|
||||
Разбор цепочки «загрузка → распознавание → матч → ревью → раскладка» (действие →
|
||||
артефакт в БД) дал естественный набор доменов; приводим capabilities к нему.
|
||||
|
||||
Ограничение: **рефакторинг только спек, код не трогаем.** Формулировки переносим
|
||||
эквивалентными — ни одно нормативное требование не должно измениться по смыслу.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Одно поведение — один capability; имя читается без знания кода.
|
||||
- Полная цепочка представлена в `openspec/specs/` (перенос из `docs/specs/`).
|
||||
- Источник истины по мигрируемым темам переезжает в OpenSpec; в `docs/specs/`
|
||||
остаётся пометка о переезде (как пилот `ingest`).
|
||||
|
||||
**Non-Goals:**
|
||||
- Никаких изменений поведения, схемы БД, кода, конфигурации.
|
||||
- Не вводим новые сущности домена (напр. «тайтл» — отдельная задача беклога).
|
||||
- Не дробим метабазу на search/match (решено: одна `metadata-match`).
|
||||
- Мульти-бот маршрутизация уведомлений — отдельная задача, здесь только фиксируем
|
||||
текущее поведение `notifier`.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Механика переноса требований между capabilities
|
||||
|
||||
OpenSpec 1.4.x поддерживает `RENAMED` только внутри одной спеки. Межспековый
|
||||
перенос оформляем парой: **`REMOVED Requirement`** в исходной спеке (с
|
||||
`**Reason**`/`**Migration**`, указывающими целевую capability) + **`ADDED
|
||||
Requirement`** в целевой. Текст требования в ADDED — эквивалент исходного
|
||||
(перенос, не переписывание). Альтернатива (просто RENAMED) не подходит:
|
||||
capability меняется, а не имя внутри одной спеки.
|
||||
|
||||
### D2. `recognition` → `recognition` + `metadata-match`
|
||||
|
||||
Разные действия: «разобрать сигналы моделью» и «найти/подтвердить запись в базе».
|
||||
`Контракт LLM на оригинальное и локализованное названия` **остаётся в
|
||||
recognition** (это требование к выходу модели), хотя используют эти поля обе:
|
||||
recognition — чтобы модель их заполнила, metadata-match — как ключи поиска.
|
||||
|
||||
| Текущее требование (recognition) | Назначение |
|
||||
|---|---|
|
||||
| Сверка с базой по нескольким названиям | → `metadata-match` |
|
||||
| Контракт LLM на оригинальное и локализованное названия | остаётся `recognition` |
|
||||
| Локаль запроса к TMDB | → `metadata-match` |
|
||||
| Нормализация названий при сравнении | → `metadata-match` |
|
||||
| Кандидат несёт URL для внешней проверки | → `metadata-match` |
|
||||
|
||||
Backfill `recognition` из `docs/specs/recognition.md` (ADDED — сейчас в OpenSpec
|
||||
этих требований нет):
|
||||
- Пред-парс имени релиза (`go-ptn`).
|
||||
- Разбор сигналов LLM в структурированный план (схема: `type`, `title`,
|
||||
`original_title`, `year`, `provider_hint`, `files[]` с per-file `season`/
|
||||
`episode`, `confidence`).
|
||||
- Провайдер LLM за абстракцией (`openai-compat`, JSON-mode, ретраи с передачей
|
||||
ошибки/схемы; неразобранный после ретраев → review, не failed).
|
||||
- Модель уверенности и решение auto/review (авто только при подтверждённом матче
|
||||
в базе + структурная валидация + согласованность с пред-парсом).
|
||||
- Роли файлов на краях (sample/extra/ignore, привязка внешних субтитров).
|
||||
|
||||
ADDED в `metadata-match` (сам поиск/подтверждение, backfill из `recognition.md`
|
||||
§3, помимо 4 перенесённых):
|
||||
- Поиск записи в TMDB/TVDB/TVMaze, сбор кандидатов с дедупом `provider:id`.
|
||||
- Подтверждение единичного сильного матча → `provider`/`provider_id`/канон. имя/год.
|
||||
- Опциональность баз: нет баз или нет матча → авто-раскладки нет (граница с
|
||||
recognition-решением; здесь — что матч не подтверждён).
|
||||
|
||||
### D3. `review` как отдельный capability
|
||||
|
||||
Переносим из `web-ui`:
|
||||
|
||||
| Текущее требование (web-ui) | Назначение |
|
||||
|---|---|
|
||||
| Единый список источников совпадения на ревью | → `review` |
|
||||
| Ручное добавление источника по id или URL | → `review` |
|
||||
| Предпросмотр полей источника до фиксации выбора | → `review` |
|
||||
|
||||
`Матч с записью метабазы ссылкой` **остаётся в web-ui** — это отображение на
|
||||
странице загрузки, а не действие ревью.
|
||||
|
||||
ADDED в `review` (миграция `docs/specs/review-ux.md`):
|
||||
- Триггеры входа в review с явной причиной.
|
||||
- Команды: Применить, Уточнить (подсказка+перераспознавание), Распознать заново,
|
||||
Тип, Игнор файла, Позже, Отклонить, Undo, Привязать заново.
|
||||
- Подсказка (мягкая, интерпретирует LLM) vs override (жёсткий пин;
|
||||
перераспознавание не затирает).
|
||||
- Единый список источников (нейронка наравне с кандидатами) + ручное добавление
|
||||
+ предпросмотр «превью = применение» (перенесены из web-ui, п. выше).
|
||||
- Разделение труда транспортов (веб — точные правки, Telegram — быстрые действия/
|
||||
эскалация в веб); одно состояние ревью, команды сериализует worker.
|
||||
|
||||
### D4. Миграция `file-layout`, `download-tracking`, `notifications` из docs/specs
|
||||
|
||||
**`file-layout`** ← `docs/specs/jellyfin-layout.md`:
|
||||
- Целевые имена фильмов (`Название (Год) [providerid-…]`).
|
||||
- Целевые имена сериалов (папка с provider-id, `Season xx`, `SxxEyy`).
|
||||
- Сопоставление источник→цель хардлинками (`save_path`+относит. имя; mkdir 0755).
|
||||
- Санитизация целевого имени и запрет выхода за библиотеку.
|
||||
- Never-overwrite (тот же inode → готово; другой файл → коллизия → review).
|
||||
- Copy-fallback при невозможности хардлинка.
|
||||
|
||||
**Владение целевым путём** (`superseded`) и **безопасный undo** (`nlink<=1` →
|
||||
`ErrLastCopy`) в `file-layout` НЕ дублируем: их доминирующая забота — сверка с
|
||||
реальностью (присутствие цели определяется по владению; отказ Undo при пропавшем
|
||||
источнике), и они уже живут более полными версиями в `state-reconciliation`.
|
||||
`file-layout` описывает акт прямой раскладки; на владение/undo он опирается,
|
||||
оставляя их дом в `state-reconciliation` (см. ревью дизайна: устранение дубля).
|
||||
|
||||
**`download-tracking`** ← `docs/specs/workflow.md` (прямой путь; сверка
|
||||
разложенного остаётся в `state-reconciliation`):
|
||||
- Поллинг qBittorrent и сопоставление его состояний с нашими.
|
||||
- Готовность только когда файлы на месте (не `moving`/`checking*`).
|
||||
- Таймауты-предохранители: `metaDL` > `magnet_timeout` → failed; `stalledDL` >
|
||||
`stuck_after` → stuck; базис возраста — `added_on` (переживает retry/усыновление).
|
||||
- Ошибка qBit (`error`/`missingFiles`) → failed (`qbit_error`).
|
||||
- Усыновление раздач по категории **или** тегу, которых нет в БД → `downloading`.
|
||||
- Владелец переходов — worker под per-download блокировкой (FSM).
|
||||
|
||||
**`notifications`** ← `workflow.md` + `state-reconciliation`:
|
||||
- Уведомление автора о падении (`failed`/`stuck`), включая приёмный `qbit_add`
|
||||
мимо поллинга.
|
||||
- Дебаунс повторных падений одной задачи (мерцающий stalled не спамит).
|
||||
- Пинг о входе в review и о готовности.
|
||||
- Уведомление о рассинхроне (перенос из `state-reconciliation`).
|
||||
|
||||
| Текущее требование (state-reconciliation) | Назначение |
|
||||
|---|---|
|
||||
| Уведомление о рассинхроне | → `notifications` |
|
||||
| (остальные 11) | остаются `state-reconciliation` |
|
||||
|
||||
### D5. `identity` → инфраструктура id; приём → `ingest`
|
||||
|
||||
| Текущее требование (identity) | Назначение |
|
||||
|---|---|
|
||||
| ULID как первичный ключ сущностей | остаётся `identity` |
|
||||
| Нормализация и валидация id на входных границах | остаётся `identity` |
|
||||
| Множество инфохэшей загрузки | → `ingest` |
|
||||
| Дедупликация приёма по любому из хешей | → `ingest` |
|
||||
| Атомарность возврата загрузки в активное состояние | → `ingest` |
|
||||
| Корреляция сущностей в логах | остаётся `identity` |
|
||||
| Миграция существующих записей | остаётся `identity` |
|
||||
|
||||
Примечание: `Атомарность возврата в активное состояние` используется и «Привязать
|
||||
заново» (review) — но это инвариант приёма/активации загрузки, поэтому его дом —
|
||||
`ingest`; review на него ссылается.
|
||||
|
||||
### D6. Формулировки — эквивалентный перенос
|
||||
|
||||
При ADDED в целевой capability текст берём из исходного требования (или из
|
||||
`docs/specs/` при backfill), сохраняя нормативную силу (SHALL/MUST) и сценарии.
|
||||
Правки — только связочные (ссылки на соседние capability), не смысловые. Это
|
||||
делает `openspec archive` безопасным: живые спеки после влития эквивалентны сумме
|
||||
прежних.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Большой diff, риск потерять требование при переносе** → Перенос по таблицам
|
||||
D2–D5 (чек-лист: каждое исходное требование учтено — либо STAY, либо REMOVED+
|
||||
ADDED). `openspec validate --strict` + сверка счётчика требований до/после.
|
||||
- **Расхождение docs/specs ↔ openspec после переезда** → В `docs/specs/`
|
||||
мигрированных файлов ставим шапку «источник истины переехал в
|
||||
`openspec/specs/<cap>`»; содержимое не дублируем.
|
||||
- **Граница recognition ↔ metadata-match может «поплыть» на будущих задачах**
|
||||
(сила совпадения кандидата — идея беклога) → Сейчас фиксируем по действию;
|
||||
дальнейшее уточнение — отдельным change.
|
||||
- **Пробел: у `ingest` нет формального требования на сам приём** (парс magnet →
|
||||
завести download → отдать в qBittorrent) — сейчас поведение не выражено
|
||||
требованием ни в одной спеке → см. Open Questions.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Дельты change: ADDED в новых/целевых спеках, REMOVED в исходных (D1).
|
||||
2. `openspec validate --strict refactor-capability-boundaries`.
|
||||
3. Ревью дизайна (этот документ) — **чекпоинт до написания всех дельт**.
|
||||
4. После апрува — генерация дельта-спек, повторная валидация.
|
||||
5. Пометки о переезде в `docs/specs/` мигрированных файлов; снять пункт беклога.
|
||||
6. `openspec archive` — влить дельты в `openspec/specs/`.
|
||||
|
||||
Откат: change не тронул код; отмена = удалить директорию change (спеки не влиты до
|
||||
archive).
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
1. **Ядро приёма в `ingest` — ДА.** Добавляем ADDED «Приём источника и заведение
|
||||
загрузки» (backfill из `architecture.md` → «Транспорты»): парс magnet → дедуп →
|
||||
завести `download`+`download_infohash` → отдать в qBittorrent; ошибка добавления
|
||||
→ `failed` (`qbit_add`). Иначе `ingest` остаётся про имя+инфохэши без ядра.
|
||||
2. **«Секция раздачи на странице загрузки» — остаётся в `live-status`** (меняется
|
||||
вместе с телеметрией).
|
||||
3. **Один change** (решение автора). При необходимости порядок дельт разложим на
|
||||
этапе apply.
|
||||
@@ -0,0 +1,80 @@
|
||||
## Why
|
||||
|
||||
Деление capabilities в OpenSpec сложилось стихийно по ходу миграции и смешивает
|
||||
разные заботы в одной спеке: `recognition` держит и разбор через LLM, и работу с
|
||||
внешними базами; поведение ревью размазано между `web-ui` и не перенесённым
|
||||
`docs/specs/review-ux.md`; ключевые звенья цепочки (раскладка, отслеживание
|
||||
загрузки, уведомления) в OpenSpec отсутствуют вовсе. На каждой задаче приходится
|
||||
гадать, какой capability трогать. Приводим набор capabilities к цепочке
|
||||
«загрузка → распознавание → матч → ревью → раскладка», где имя capability
|
||||
отвечает **одному** поведению.
|
||||
|
||||
Это чисто спецификационный рефакторинг: **поведение системы не меняется**, код не
|
||||
трогаем. Меняются только границы и расположение требований.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Разделяем `recognition`**: оставляем в нём только разбор сигналов LLM (план,
|
||||
тип, название, файлы→серии, модель уверенности, решение auto/review); всю работу
|
||||
с метабазами выносим в новую `metadata-match`.
|
||||
- **Вводим `review`** как отдельный capability: переносим поведение ревью из
|
||||
`web-ui` (единый список источников, ручное добавление источника, предпросмотр
|
||||
полей) и мигрируем `docs/specs/review-ux.md`.
|
||||
- **Подрезаем `web-ui`** до чистого оформления: статика, шрифты, дизайн-система,
|
||||
скелет страниц, бейджи, клиентские взаимодействия, превью через единую логику
|
||||
именования.
|
||||
- **Мигрируем в OpenSpec** три звена цепочки, живущие пока в `docs/specs`:
|
||||
`file-layout` (из `jellyfin-layout.md`), `download-tracking` (FSM/поллинг из
|
||||
`workflow.md`), `notifications` (из `workflow.md` + `architecture.md`).
|
||||
- **Чистим `identity`** до инфраструктуры id: переносим приёмные требования
|
||||
(инфохэши, дедуп, атомарный возврат в активное) в `ingest`.
|
||||
- **Собираем уведомления** в `notifications`: переносим «Уведомление о
|
||||
рассинхроне» из `state-reconciliation`.
|
||||
- Не-BREAKING: перенос требований (REMOVED в исходной спеке + ADDED в целевой),
|
||||
формулировки сохраняем эквивалентными.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `metadata-match`: поиск записи во внешних базах (TMDB/TVDB/TVMaze) по названиям,
|
||||
сбор кандидатов, подтверждение единичного сильного матча → `provider`/
|
||||
`provider_id`/каноническое имя/год, URL кандидата.
|
||||
- `review`: процесс ревью после распознавания и матча — петля «догадка → подсказка
|
||||
→ перераспознавание», команды (Применить/Уточнить/Распознать заново/Тип/Игнор/
|
||||
Позже/Отклонить/Undo/Привязать заново), подсказка vs override, единый список
|
||||
источников совпадения, ручное добавление источника, предпросмотр (превью=
|
||||
применение), разделение труда транспортов.
|
||||
- `file-layout`: целевые имена фильмов/сериалов, сопоставление источник→цель,
|
||||
хардлинки, санитизация пути и запрет выхода за библиотеку, never-overwrite,
|
||||
copy-fallback. Владение путём (`superseded`) и безопасный undo (`nlink<=1`)
|
||||
остаются в `state-reconciliation` (не дублируем — см. design.md).
|
||||
- `download-tracking`: поллинг qBittorrent и машина состояний загрузки —
|
||||
`downloading`/`completed`/`stuck`/`failed`, завершение (moving/checking),
|
||||
таймауты (`magnet_timeout`/`stuck_after`), усыновление по категории/тегу.
|
||||
- `notifications`: пинги автору загрузки — падения (`failed`/`stuck`, включая
|
||||
приёмный `qbit_add` мимо поллинга) с дебаунсом, вход в review и готовность,
|
||||
рассинхрон.
|
||||
|
||||
### Modified Capabilities
|
||||
- `recognition`: убираем требования по метабазам (уезжают в `metadata-match`);
|
||||
добавляем перенесённый из `docs/specs/recognition.md` разбор сигналов LLM
|
||||
(пред-парс, схема плана, провайдер LLM, модель уверенности).
|
||||
- `ingest`: принимает перенесённые из `identity` приёмные требования (множество
|
||||
инфохэшей, дедуп по любому хешу, атомарный возврат в активное состояние).
|
||||
- `identity`: убираем приёмные требования — остаётся инфраструктура id (ULID,
|
||||
нормализация/валидация, корреляция в логах, миграция).
|
||||
- `web-ui`: убираем ревью-специфику (уезжает в `review`) — остаётся оформление.
|
||||
- `state-reconciliation`: убираем «Уведомление о рассинхроне» (уезжает в
|
||||
`notifications`).
|
||||
|
||||
## Impact
|
||||
|
||||
- Затрагивает только `openspec/specs/**` и `openspec/changes/**` — **кода нет**.
|
||||
- Источник истины по мигрируемым темам переезжает из `docs/specs/`
|
||||
(`recognition.md`, `review-ux.md`, `jellyfin-layout.md`, `workflow.md`) в
|
||||
`openspec/specs/` (как пилот `ingest`); в исходных файлах ставим пометку о
|
||||
переезде, содержимое не дублируем.
|
||||
- CLAUDE.md и `docs/backlog.md`: снять пункт из беклога, при необходимости
|
||||
поправить перечисление capabilities.
|
||||
- Валидация: `openspec validate --strict` для change перед коммитом; после архива
|
||||
дельты вливаются в `openspec/specs/`.
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Поллинг qBittorrent и сопоставление состояний
|
||||
|
||||
Worker SHALL периодически (`worker.poll_interval`, дефолт 5 с) опрашивать
|
||||
qBittorrent и сопоставлять его состояния раздачи с состоянием загрузки в БД.
|
||||
Готовые к раскладке состояния (`uploading`/`stalledUP`/`pausedUP`/`stoppedUP`/
|
||||
`queuedUP`/`forcedUP`, с учётом различий имён между qBit v4 и v5) SHALL переводить
|
||||
загрузку в `completed`. Ещё качающиеся состояния (`downloading`/`stalledDL`/
|
||||
`metaDL`/…) SHALL оставлять её в `downloading`.
|
||||
|
||||
#### Scenario: Раздача завершилась
|
||||
|
||||
- **GIVEN** загрузка в `downloading`
|
||||
- **WHEN** qBittorrent сообщает состояние `stalledUP` и файлы на месте
|
||||
- **THEN** загрузка переходит в `completed`
|
||||
|
||||
### Requirement: Готовность только когда файлы на месте
|
||||
|
||||
Переходные состояния qBittorrent система SHALL трактовать как «ждём»
|
||||
(`moving`/`checkingUP`/`checkingResumeData`/`allocating`): оставаться в
|
||||
`downloading` и НЕ объявлять готовность, даже если выставлены флаги `UP`, пока
|
||||
qBit не завершит перенос/проверку. Финальные пути файлов система SHALL брать из
|
||||
API после завершения переноса.
|
||||
|
||||
#### Scenario: Ждём завершения переноса
|
||||
|
||||
- **GIVEN** загрузка, у которой qBittorrent в состоянии `moving`
|
||||
- **WHEN** идёт тик поллинга
|
||||
- **THEN** загрузка остаётся в `downloading`, готовность не объявляется
|
||||
|
||||
### Requirement: Таймауты-предохранители downloading
|
||||
|
||||
Система SHALL переводить `metaDL`/`forcedMetaDL` дольше `magnet_timeout` (дефолт
|
||||
`24h`, редкий предохранитель) в `failed` (`error_code` `magnet_timeout`), а
|
||||
`stalledDL` дольше `stuck_after` — в `stuck` (`error_code`
|
||||
`stalled`). Возраст система SHALL считать от времени добавления в qBittorrent
|
||||
(`added_on`), а не от создания задачи, чтобы базис переживал retry и усыновление.
|
||||
Долгий `metaDL` система НЕ SHALL убивать агрессивно (медленные трекеры — норма).
|
||||
|
||||
#### Scenario: Завис на метаданных дольше таймаута
|
||||
|
||||
- **GIVEN** раздача в `metaDL` дольше `magnet_timeout` от `added_on`
|
||||
- **WHEN** идёт тик поллинга
|
||||
- **THEN** загрузка переходит в `failed` с `error_code` `magnet_timeout`
|
||||
|
||||
### Requirement: Ошибка qBittorrent переводит в failed
|
||||
|
||||
Состояния `error`/`missingFiles` система SHALL трактовать как настоящий провал и
|
||||
переводить загрузку в `failed` (`error_code` `qbit_error`) — в отличие от
|
||||
таймаутов-предохранителей, такой провал сверкой не воскрешается.
|
||||
|
||||
#### Scenario: qBit сообщает об ошибке
|
||||
|
||||
- **GIVEN** раздача в состоянии `missingFiles`
|
||||
- **WHEN** идёт тик поллинга
|
||||
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_error`
|
||||
|
||||
### Requirement: Усыновление раздач по категории или тегу
|
||||
|
||||
Worker SHALL периодически сверять раздачи qBittorrent с БД и **усыновлять** те, у
|
||||
которых наша категория (`qbittorrent.category`) ИЛИ тег (`qbittorrent.tag`), а
|
||||
записи в БД ещё нет, заводя для них загрузку в состоянии `downloading`. Категория
|
||||
ставится на добавляемые нами раздачи (push); тег позволяет подхватить уже
|
||||
существующую раздачу (pull), не трогая её категорию и файлы.
|
||||
|
||||
#### Scenario: Подхват существующей раздачи по тегу
|
||||
|
||||
- **GIVEN** в qBittorrent есть раздача с тегом `qbittorrent.tag`, которой нет в БД
|
||||
- **WHEN** worker сверяет qBittorrent с БД
|
||||
- **THEN** для раздачи заводится загрузка в состоянии `downloading`
|
||||
|
||||
### Requirement: Переходы состояний под per-download блокировкой
|
||||
|
||||
Все переходы состояний загрузки SHALL проходить через worker под per-download
|
||||
блокировкой, чтобы два транспорта не гонялись за одно состояние. Состояние SHALL
|
||||
быть персистентным в SQLite; активность загрузки SHALL выводиться только из
|
||||
`state`, без отдельного флага.
|
||||
|
||||
#### Scenario: Команды сериализуются
|
||||
|
||||
- **GIVEN** две одновременные команды к одной загрузке из разных транспортов
|
||||
- **WHEN** они обрабатываются
|
||||
- **THEN** переходы применяются последовательно под блокировкой, без гонки
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Целевые имена фильмов
|
||||
|
||||
Фильм система SHALL раскладывать в папку и файл вида `Название (Год)`, помещённые
|
||||
под `paths.movies`. При подтверждённом матче в базе имя папки SHALL нести
|
||||
provider-id (`[tmdbid-…]`/`[tvdbid-…]`) — он снимает неоднозначность русских
|
||||
названий для Jellyfin. Внешние субтитры SHALL именоваться `Имя.<lang>[.flag].srt`
|
||||
(флаги `forced`/`sdh`/`default`/`hi`), с базой имени, совпадающей с именем
|
||||
видеофайла; пары VobSub — `.idx` + `.sub`.
|
||||
|
||||
#### Scenario: Фильм с provider-id
|
||||
|
||||
- **GIVEN** распознанный фильм «Дюна Часть вторая» (2024) с матчем TMDB `693134`
|
||||
- **WHEN** строится целевой путь
|
||||
- **THEN** папка = `movies/Дюна Часть вторая (2024) [tmdbid-693134]/`
|
||||
- **AND** видеофайл = `Дюна Часть вторая (2024).mkv`
|
||||
|
||||
### Requirement: Целевые имена сериалов
|
||||
|
||||
Сериал система SHALL раскладывать под `paths.series` в папку `Название (Год)` с
|
||||
provider-id на папке сериала, сезонными подпапками `Season xx` и файлами вида
|
||||
`Название (Год) SxxEyy`.
|
||||
|
||||
#### Scenario: Серия сезона
|
||||
|
||||
- **GIVEN** распознанный сериал «Фарго» (2024) с матчем TVDB `123456`, серия S01E02
|
||||
- **WHEN** строится целевой путь
|
||||
- **THEN** путь = `series/Фарго (2024) [tvdbid-123456]/Season 01/Фарго (2024) S01E02.mkv`
|
||||
|
||||
### Requirement: Сопоставление источник → цель хардлинками
|
||||
|
||||
Для каждого распознанного **файла** (не каталога) система SHALL создавать
|
||||
**хардлинк** в `paths.movies`/`paths.series`; исходный путь берётся из
|
||||
qBittorrent (`save_path` + относительное имя файла из `/torrents/files`, уже
|
||||
включающее корневую папку многофайловой раздачи). Целевые каталоги SHALL
|
||||
создаваться `mkdir` (0755, `1000:1000`). Исходный файл система НЕ SHALL трогать —
|
||||
раздача продолжается, inode общий, диск не дублируется.
|
||||
|
||||
#### Scenario: Хардлинк не дублирует данные
|
||||
|
||||
- **GIVEN** видеофайл раздачи под `paths.downloads`
|
||||
- **WHEN** файл раскладывается
|
||||
- **THEN** в библиотеке создаётся хардлинк на тот же inode
|
||||
- **AND** исходный файл остаётся на месте
|
||||
|
||||
### Requirement: Санитизация целевого пути и запрет выхода за библиотеку
|
||||
|
||||
Целевое имя система SHALL санитизировать (без разделителей пути, `..`,
|
||||
управляющих символов), а финальный путь SHALL проверять на строгое нахождение под
|
||||
`paths.movies`/`paths.series`. Путь, выходящий за пределы библиотеки, система НЕ
|
||||
SHALL создавать. Безопасность SHALL держаться на валидации пути, а не на доверии к
|
||||
выходу LLM.
|
||||
|
||||
#### Scenario: Traversal отклоняется
|
||||
|
||||
- **GIVEN** распознанное имя, содержащее `../`
|
||||
- **WHEN** строится и проверяется целевой путь
|
||||
- **THEN** путь отклоняется как выходящий за пределы библиотеки, хардлинк не создаётся
|
||||
|
||||
### Requirement: Существующую цель не перезаписываем
|
||||
|
||||
Существующий целевой файл система НЕ SHALL перезаписывать. Если по целевому пути
|
||||
уже лежит тот же inode — операция идемпотентна (готово); если другой файл —
|
||||
это коллизия, и задача SHALL уходить в `review`.
|
||||
|
||||
#### Scenario: Коллизия уходит в review
|
||||
|
||||
- **GIVEN** по целевому пути уже лежит другой файл
|
||||
- **WHEN** выполняется раскладка
|
||||
- **THEN** файл не перезаписывается, задача переходит в `review` с причиной коллизии
|
||||
|
||||
### Requirement: Copy-fallback при невозможности хардлинка
|
||||
|
||||
Система SHALL при невозможности хардлинка (разные ФС или ФС без поддержки жёстких
|
||||
ссылок) НЕ падать, а копировать файл с предупреждением в лог, помечая ссылку
|
||||
статусом `copied`.
|
||||
|
||||
#### Scenario: Разные ФС — копирование
|
||||
|
||||
- **GIVEN** целевой и исходный каталоги на разных ФС
|
||||
- **WHEN** выполняется раскладка файла
|
||||
- **THEN** файл копируется, ссылка получает статус `copied`, в лог пишется предупреждение
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Множество инфохэшей загрузки
|
||||
|
||||
**Reason**: Инфохэши — часть приёма загрузки, а не инфраструктуры id; поведение
|
||||
относится к capability `ingest`.
|
||||
**Migration**: Требование перенесено без изменений в `ingest` (см. `specs/ingest`).
|
||||
|
||||
### Requirement: Дедупликация приёма по любому из хешей
|
||||
|
||||
**Reason**: Дедуп приёма — поведение приёма загрузки, а не инфраструктуры id.
|
||||
**Migration**: Требование перенесено без изменений в `ingest`.
|
||||
|
||||
### Requirement: Атомарность возврата загрузки в активное состояние
|
||||
|
||||
**Reason**: Инвариант «не более одной активной загрузки на infohash» — забота
|
||||
приёма/активации загрузки; логичнее держать рядом с приёмом.
|
||||
**Migration**: Требование перенесено без изменений в `ingest`; review и retry на
|
||||
него ссылаются.
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Приём источника и заведение загрузки
|
||||
|
||||
Приём SHALL быть единым use-case, общим для всех транспортов (HTTP, Telegram,
|
||||
CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL извлечь
|
||||
инфохэши, дедуплицировать по активной задаче, при отсутствии дубля завести
|
||||
загрузку (`download` в состоянии `downloading` + записи `download_infohash`) и
|
||||
отдать источник в qBittorrent (категория `qbittorrent.category`, savepath). Если
|
||||
добавление в qBittorrent не удалось, система SHALL перевести уже заведённую
|
||||
загрузку в `failed` (`error_code` `qbit_add`) и уведомить автора. Заведение
|
||||
загрузки и запись её хешей SHALL выполняться атомарно (см. «Атомарность возврата
|
||||
загрузки в активное состояние»).
|
||||
|
||||
#### Scenario: Успешный приём magnet
|
||||
|
||||
- **GIVEN** валидная magnet-ссылка и контекст
|
||||
- **WHEN** вызывается приём
|
||||
- **THEN** создаётся `download` в `downloading` с записями `download_infohash`
|
||||
- **AND** источник отдан в qBittorrent с нашей категорией
|
||||
|
||||
#### Scenario: Падение добавления в qBittorrent
|
||||
|
||||
- **GIVEN** заведённую загрузку не удалось добавить в qBittorrent
|
||||
- **WHEN** обрабатывается ошибка добавления
|
||||
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add`
|
||||
- **AND** автор загрузки уведомляется
|
||||
|
||||
### Requirement: Множество инфохэшей загрузки
|
||||
|
||||
Загрузка SHALL иметь одну или более записей инфохэша (`download_infohash`:
|
||||
`infohash` lowercase hex, `kind` ∈ `v1`|`v2`). При приёме magnet-ссылки
|
||||
SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и
|
||||
btih (v1), и btmh (v2); `kind` определяется по длине hex (40 — `v1`, 64 —
|
||||
`v2`). Когда qBittorrent сообщает для раздачи оба хеша (`infohash_v1`,
|
||||
`infohash_v2`), система SHALL дописывать недостающие записи загрузке;
|
||||
усечённый хеш v2-only раздачи (поле `hash` qBittorrent, 40 hex от v2)
|
||||
записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой
|
||||
(поллинг, discover) SHALL выполняться по любому из известных хешей. Один и
|
||||
тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный
|
||||
приём после терминального состояния), но активной из них MUST быть не более
|
||||
одной.
|
||||
|
||||
#### Scenario: Гибридный торрент раскрывает оба хеша
|
||||
|
||||
- **GIVEN** загрузка принята по magnet с v1-хешем
|
||||
- **WHEN** qBittorrent отдаёт раздачу с заполненными `infohash_v1` и
|
||||
`infohash_v2`
|
||||
- **THEN** у загрузки появляются обе записи (`kind` = `v1` и `v2`)
|
||||
|
||||
#### Scenario: Сопоставление по v2-хешу
|
||||
|
||||
- **GIVEN** загрузка с записями v1- и v2-хешей
|
||||
- **WHEN** поллинг находит раздачу, совпавшую только по v2-хешу
|
||||
- **THEN** раздача сопоставляется с этой загрузкой
|
||||
|
||||
### Requirement: Дедупликация приёма по любому из хешей
|
||||
|
||||
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
|
||||
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
|
||||
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
|
||||
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
|
||||
более одной активной загрузки на infohash». Отдельного снимаемого/
|
||||
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
|
||||
выводится только из `state`.
|
||||
|
||||
#### Scenario: Повторный приём при активной загрузке
|
||||
|
||||
- **GIVEN** активная загрузка с infohash `h`
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
|
||||
#### Scenario: Повторный приём после завершения
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
|
||||
|
||||
### Requirement: Атомарность возврата загрузки в активное состояние
|
||||
|
||||
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
|
||||
возвращающем загрузку из терминального состояния в активное (ручной retry,
|
||||
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
|
||||
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
|
||||
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
|
||||
сохраняя инвариант «не более одной активной загрузки на infohash».
|
||||
Отказ SHALL происходить до побочных эффектов во внешних системах
|
||||
(повторного добавления торрента в qBittorrent).
|
||||
|
||||
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
|
||||
гибридного торрента): хеш, которым владеет другая активная загрузка,
|
||||
дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное
|
||||
состояние в обход этой проверки SHALL отклоняться хранилищем (механический
|
||||
бэкстоп вместо удалённого unique-индекса).
|
||||
|
||||
#### Scenario: Retry при занятом хеше
|
||||
|
||||
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
|
||||
#2 с тем же `h`
|
||||
- **WHEN** пользователь вызывает retry для #1
|
||||
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
|
||||
- **AND** активной по `h` остаётся #2
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Сверка с базой по нескольким названиям
|
||||
|
||||
При сверке плана с включёнными базами метаданных система SHALL искать по
|
||||
нескольким названиям в порядке убывания силы ключа: сначала по
|
||||
`original_title`, затем по локализованному `title`, затем по `provider_hint`.
|
||||
Поиск SHALL останавливаться, как только очередной запрос дал единичный
|
||||
сильный матч (ровно один кандидат с совпадением названия и года). Запрос с
|
||||
названием, нормализованно совпадающим с уже выполненным, система SHALL
|
||||
пропускать, чтобы не обращаться к базе повторно с тем же ключом.
|
||||
|
||||
Кандидаты для ручного выбора в review система SHALL собирать из всех
|
||||
выполненных заходов с дедупликацией по `provider:id` и общим потолком.
|
||||
|
||||
#### Scenario: Иностранный фильм находится по оригинальному названию
|
||||
|
||||
- **GIVEN** план с `title` «Тёмный рыцарь», `original_title` «The Dark Knight», год 2008
|
||||
- **WHEN** выполняется сверка с базой
|
||||
- **THEN** первый запрос идёт по «The Dark Knight»
|
||||
- **AND** при единичном сильном матче дальнейшие запросы (по `title`, `provider_hint`) не выполняются
|
||||
|
||||
#### Scenario: Фолбэк на локализованное название
|
||||
|
||||
- **GIVEN** план, для которого запрос по `original_title` не дал единичного сильного матча
|
||||
- **WHEN** продолжается сверка
|
||||
- **THEN** выполняется запрос по локализованному `title`
|
||||
- **AND** при отсутствии матча и там — запрос по `provider_hint`
|
||||
|
||||
#### Scenario: Дублирующий запрос пропускается
|
||||
|
||||
- **GIVEN** план, у которого `original_title` нормализованно совпадает с `title`
|
||||
- **WHEN** выполняется сверка
|
||||
- **THEN** база запрашивается этим названием один раз, повторный заход по `title` не делается
|
||||
|
||||
### Requirement: Подтверждение матча и каноническое имя
|
||||
|
||||
При единичном сильном матче система SHALL брать из записи базы официальный
|
||||
`provider` (`tmdb`|`tvdb`|`tvmaze`) и `provider_id`, а также каноническое название
|
||||
и год, и подменять ими соответствующие поля плана (для сериала — с учётом внешнего
|
||||
тега TVDB/IMDb из `externals`, идущего в имя папки). Матч SHALL считаться
|
||||
подтверждённым только при ровно одном сильном кандидате; при нуле или нескольких
|
||||
кандидатах подтверждённого матча быть SHALL NOT (авто-раскладка не разрешается,
|
||||
кандидаты уходят в review). Работа с базами опциональна: при выключенных базах
|
||||
сверка не выполняется и подтверждённого матча нет.
|
||||
|
||||
#### Scenario: Единичный матч даёт id и каноническое имя
|
||||
|
||||
- **GIVEN** поиск вернул ровно одного сильного кандидата TMDB для фильма
|
||||
- **WHEN** матч подтверждается
|
||||
- **THEN** план получает `provider`=`tmdb`, `provider_id`, каноническое название и год
|
||||
|
||||
#### Scenario: Несколько кандидатов — матч не подтверждён
|
||||
|
||||
- **GIVEN** поиск вернул более одного подходящего кандидата
|
||||
- **WHEN** оценивается матч
|
||||
- **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review
|
||||
|
||||
### Requirement: Локаль запроса к TMDB
|
||||
|
||||
Запрос поиска к TMDB SHALL передавать параметр `language`, по умолчанию
|
||||
`ru-RU`, со значением, настраиваемым конфигом `[metadata.tmdb].language`.
|
||||
Это влияет только на локализованное поле `Title`/`Name`; поле
|
||||
`original_title`/`original_name` остаётся на языке оригинала, поэтому
|
||||
оригинальная сторона сравнения не затрагивается.
|
||||
|
||||
#### Scenario: Локализованный заголовок приходит по-русски
|
||||
|
||||
- **GIVEN** TMDB включён, `language` не задан в конфиге
|
||||
- **WHEN** выполняется поиск фильма с русской локализацией
|
||||
- **THEN** запрос содержит `language=ru-RU`
|
||||
- **AND** в кандидате `Title` приходит на русском, а `OriginalTitle` — на языке оригинала
|
||||
|
||||
### Requirement: Нормализация названий при сравнении
|
||||
|
||||
Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`,
|
||||
чтобы написания, различающиеся только `ё`/`е`, считались одним названием.
|
||||
|
||||
#### Scenario: «Тёмный» и «Темный» совпадают
|
||||
|
||||
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
|
||||
- **WHEN** сравниваются нормализованные названия
|
||||
- **THEN** они считаются совпадающими
|
||||
|
||||
### Requirement: Кандидат несёт URL для внешней проверки
|
||||
|
||||
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
|
||||
поле `URL` — ссылку на страницу элемента (фильма/сериала) на сайте
|
||||
провайдера. URL SHALL формироваться клиентом провайдера при поиске
|
||||
(`Search`) и сохраняться в таблице `metadata_candidate`. Отображение этой
|
||||
ссылки на экране ревью — забота `review`/`web-ui`, не данного требования.
|
||||
|
||||
Формат URL для каждого провайдера:
|
||||
|
||||
- **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или
|
||||
`https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из
|
||||
запроса `Query.Type`
|
||||
- **TVDB**: `https://www.thetvdb.com/dereferrer/series/{id}`
|
||||
- **TVMaze**: `https://www.tvmaze.com/shows/{id}` — URL SHALL использовать
|
||||
нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на
|
||||
TVMaze-страницу
|
||||
|
||||
#### Scenario: Кандидат TMDB с корректной ссылкой
|
||||
|
||||
- **GIVEN** TMDB найден кандидат-фильм с id `603` («Матрица»)
|
||||
- **WHEN** клиент TMDB формирует Candidate
|
||||
- **THEN** `URL` = `https://www.themoviedb.org/movie/603`
|
||||
|
||||
#### Scenario: Кандидат TVMaze с нативной ссылкой
|
||||
|
||||
- **GIVEN** TVMaze найден сериал с id `169` («Фарго»), внешний тег — TVDB id `269613`
|
||||
- **WHEN** клиент TVMaze формирует Candidate
|
||||
- **THEN** `URL` = `https://www.tvmaze.com/shows/169`
|
||||
- **AND** `TagProvider`/`TagID` остаются `tvdb`/`269613` (тег папки Jellyfin не меняется)
|
||||
|
||||
#### Scenario: URL сохраняется в БД
|
||||
|
||||
- **GIVEN** результат поиска с кандидатами
|
||||
- **WHEN** кандидаты сохраняются в таблицу `metadata_candidate`
|
||||
- **THEN** значение `url` SHALL быть записано в колонку `url`
|
||||
- **AND** при последующей загрузке данных ревью url доступен без повторной
|
||||
генерации
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Уведомление о падении загрузки
|
||||
|
||||
Любой переход загрузки в `failed`/`stuck` система SHALL сопровождать уведомлением
|
||||
автора загрузки через настроенный механизм (`notifier`), чтобы падение не
|
||||
оставалось незамеченным. Это SHALL включать приёмное падение `qbit_add` (не
|
||||
удалось добавить раздачу в qBittorrent), которое идёт мимо поллинг-цикла worker.
|
||||
|
||||
#### Scenario: Уведомление при падении приёма
|
||||
|
||||
- **GIVEN** приём загрузки, где добавление в qBittorrent не удалось
|
||||
- **WHEN** загрузка помечается `failed` с `error_code` `qbit_add`
|
||||
- **THEN** автор загрузки получает уведомление о падении
|
||||
|
||||
### Requirement: Дебаунс повторных падений
|
||||
|
||||
Повторные падения одной задачи в пределах окна дебаунса система SHALL уведомлять
|
||||
лишь один раз, чтобы мерцающий stalled-торрент (`stuck` ↔ `downloading`) не спамил
|
||||
автора.
|
||||
|
||||
#### Scenario: Мерцающий stalled не спамит
|
||||
|
||||
- **GIVEN** задача, многократно переходящая `stuck` ↔ `downloading` в пределах окна дебаунса
|
||||
- **WHEN** происходят повторные падения
|
||||
- **THEN** уведомление отправляется один раз за окно
|
||||
|
||||
### Requirement: Пинг о входе в review и готовности
|
||||
|
||||
При переходе загрузки в `review` система SHALL пинговать автора (сообщение в
|
||||
Telegram / бейдж в вебе) — пользователя зовут, а не он опрашивает. После
|
||||
успешного применения (готовность) система SHALL показывать, что создано.
|
||||
|
||||
#### Scenario: Пинг при входе в review
|
||||
|
||||
- **GIVEN** загрузка переходит в `review`
|
||||
- **WHEN** происходит переход
|
||||
- **THEN** автор получает пинг с приглашением подтвердить раскладку
|
||||
|
||||
### Requirement: Уведомление о рассинхроне
|
||||
|
||||
При переходе задачи в `orphaned` или `target_missing` система SHALL
|
||||
уведомлять автора загрузки через настроенный механизм уведомлений
|
||||
(`notifier`), чтобы рассинхрон не оставался незамеченным.
|
||||
|
||||
#### Scenario: Уведомление при потере источника
|
||||
|
||||
- **WHEN** задача переходит в `orphaned`
|
||||
- **THEN** автор загрузки получает уведомление о рассинхроне
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Пред-парс имени релиза
|
||||
|
||||
Перед вызовом LLM система SHALL выполнять дешёвый пред-парс имени торрента
|
||||
(`go-ptn`): извлекать черновые название, год, сезон, серию и качество. Результат
|
||||
пред-парса SHALL использоваться как вспомогательный сигнал в промпте и как
|
||||
сторона проверки согласованности при решении auto/review, но НЕ SHALL считаться
|
||||
итоговым распознаванием.
|
||||
|
||||
#### Scenario: Пред-парс даёт черновые поля
|
||||
|
||||
- **WHEN** на вход распознавания поступает имя релиза `Fargo.S02.2015.WEB-DL.1080p`
|
||||
- **THEN** пред-парс возвращает черновые `title`, `year`, `season`, `quality`
|
||||
- **AND** эти значения передаются в промпт LLM как подсказка
|
||||
|
||||
### Requirement: Разбор сигналов LLM в структурированный план
|
||||
|
||||
Система SHALL передавать LLM недоверенные сигналы (имя торрента, дерево файлов с
|
||||
размерами, текстовый контекст и накопленные подсказки, пред-парс) и получать
|
||||
структурированный план в схеме: `type` (`movie`|`series`), `title`,
|
||||
`original_title`, `year`, `provider_hint`, `files[]` и `confidence`. Каждый
|
||||
элемент `files[]` SHALL нести `src`, `role`
|
||||
(`main`|`episode`|`subtitle`|`extra`|`sample`|`ignore`) и, для сериала,
|
||||
per-file `season`/`episode` (отдельного скалярного `season` быть SHALL NOT — так
|
||||
выражаются мультисезонные паки и спецвыпуски). План SHALL приниматься только
|
||||
если каждый `files[].src` совпадает с реальным файлом торрента.
|
||||
|
||||
#### Scenario: План сериала с per-file нумерацией
|
||||
|
||||
- **GIVEN** сезон-пак из 10 видеофайлов
|
||||
- **WHEN** LLM возвращает план
|
||||
- **THEN** `type` = `series`, а каждый видеофайл несёт свои `season`/`episode`
|
||||
|
||||
#### Scenario: Несуществующий src отклоняется
|
||||
|
||||
- **GIVEN** ответ LLM, где `files[].src` не совпадает ни с одним файлом торрента
|
||||
- **WHEN** план разбирается
|
||||
- **THEN** такой план не принимается как валидный
|
||||
|
||||
### Requirement: Провайдер LLM за абстракцией со структурированным выводом
|
||||
|
||||
Доступ к LLM SHALL быть за интерфейсом с выбором реализации по полю `[llm].type`
|
||||
(первый тип — `openai-compat`). Система SHALL запрашивать JSON-режим
|
||||
(`response_format: {"type":"json_object"}`), срезать ```-ограждения и
|
||||
валидировать ответ в Go против схемы плана. При ошибке разбора система SHALL
|
||||
ретраить до `[llm].max_retries`, передавая модели саму ошибку и схему. Если после
|
||||
ретраев ответ не разобран, задача SHALL уходить в `review` (НЕ в `failed`) с
|
||||
причиной «ответ LLM не разобран».
|
||||
|
||||
#### Scenario: Неразобранный ответ уходит в review
|
||||
|
||||
- **GIVEN** LLM, чей ответ не проходит валидацию схемы после всех ретраев
|
||||
- **WHEN** завершается распознавание
|
||||
- **THEN** задача переходит в `review` с причиной «ответ LLM не разобран»
|
||||
- **AND** задача НЕ переходит в `failed`
|
||||
|
||||
### Requirement: Модель уверенности и решение auto/review
|
||||
|
||||
Система SHALL раскладывать автоматически (без review) только при выполнении
|
||||
ВСЕГО: (1) подтверждённый единичный сильный матч в базе (`metadata-match`) с
|
||||
`provider_id`; (2) структурная валидация без предупреждений (фильм — ровно один
|
||||
основной видеофайл; сериал — число серий бьётся с базой, нумерация S·E
|
||||
консистентна); (3) согласованность пред-парса и LLM по типу/названию/году. Иначе
|
||||
задача SHALL уходить в `review` с явной причиной. Самооценку LLM (`confidence`)
|
||||
система SHALL учитывать лишь как вспомогательный сигнал, НЕ как единственный гейт.
|
||||
|
||||
#### Scenario: Нет матча в базе — всегда review
|
||||
|
||||
- **GIVEN** план без подтверждённого матча в базе (база выключена или матча нет)
|
||||
- **WHEN** принимается решение auto/review
|
||||
- **THEN** задача уходит в `review`, авто-раскладка не делается
|
||||
|
||||
#### Scenario: Матч и чистая валидация — авто
|
||||
|
||||
- **GIVEN** подтверждённый единичный матч, чистая структурная валидация и
|
||||
согласованность сигналов
|
||||
- **WHEN** принимается решение
|
||||
- **THEN** допускается авто-раскладка (при отсутствии `force_review`)
|
||||
|
||||
### Requirement: Роли файлов на краях раздачи
|
||||
|
||||
Система SHALL относить семплы, «экстра» и мусор к роли `ignore` (эвристики размер/
|
||||
имя + LLM), а внешние субтитры (`.srt`, `.ass`, пары VobSub `.idx`+`.sub`) —
|
||||
привязывать к соответствующему видео. Любую неоднозначность нумерации (дыры,
|
||||
дубли, спорные спецвыпуски) система SHALL эскалировать в `review`, а не разрешать
|
||||
молча.
|
||||
|
||||
#### Scenario: Семпл помечается ignore
|
||||
|
||||
- **GIVEN** раздача с файлом `sample.mkv` малого размера
|
||||
- **WHEN** строится план
|
||||
- **THEN** этот файл получает роль `ignore` и в раскладку не попадает
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Сверка с базой по нескольким названиям
|
||||
|
||||
**Reason**: Работа с внешними базами метаданных — отдельное поведение; выделена в
|
||||
capability `metadata-match`.
|
||||
**Migration**: Требование перенесено без изменений в `metadata-match` (см.
|
||||
`specs/metadata-match`).
|
||||
|
||||
### Requirement: Локаль запроса к TMDB
|
||||
|
||||
**Reason**: Относится к работе с метабазой (TMDB), выделенной в `metadata-match`.
|
||||
**Migration**: Требование перенесено без изменений в `metadata-match`.
|
||||
|
||||
### Requirement: Нормализация названий при сравнении
|
||||
|
||||
**Reason**: Нормализация — часть сверки с метабазой, выделенной в `metadata-match`.
|
||||
**Migration**: Требование перенесено без изменений в `metadata-match`.
|
||||
|
||||
### Requirement: Кандидат несёт URL для внешней проверки
|
||||
|
||||
**Reason**: Кандидат — сущность метабазы; контракт кандидата относится к
|
||||
`metadata-match`.
|
||||
**Migration**: Требование перенесено без изменений в `metadata-match`.
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Вход в review с явной причиной
|
||||
|
||||
Когда модель уверенности не разрешает авто-раскладку, система SHALL переводить
|
||||
загрузку в `review` и SHALL показывать **конкретную причину** (низкая самооценка
|
||||
LLM; нет матча в базе или несколько кандидатов; предупреждение структурной
|
||||
валидации; неразобранный ответ LLM), а не обобщённое «не уверен». Поверхность
|
||||
решения SHALL быть единой для всех транспортов и содержать источник (имя, контекст,
|
||||
дерево файлов), догадку системы (тип, название, год, матч) и превью целевой
|
||||
раскладки.
|
||||
|
||||
#### Scenario: Причина видна в интерфейсе
|
||||
|
||||
- **GIVEN** загрузка ушла в `review` из-за отсутствия матча в базе
|
||||
- **WHEN** пользователь открывает ревью
|
||||
- **THEN** показана конкретная причина (напр. «нет в TMDB · уверенность 0.46»)
|
||||
|
||||
### Requirement: Команды ревью и их эффекты
|
||||
|
||||
Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по
|
||||
эффективному плану), **Уточнить** (добавить подсказку → перераспознать),
|
||||
**Распознать заново** (повторный прогон без новой подсказки), **Тип** (переключить
|
||||
movie↔series), **Игнор файла**, **Позже** (`deferred`), **Отклонить**
|
||||
(`cancelled`), **Undo** (снять созданные ссылки → `reverted`) и **Привязать
|
||||
заново** (из `reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
|
||||
подтверждением). Команды из любого транспорта SHALL сериализоваться worker'ом под
|
||||
per-download блокировкой; применяется последняя валидная команда. Команды,
|
||||
которым нужен источник, SHALL проверять его наличие синхронно перед действием.
|
||||
|
||||
#### Scenario: Применение создаёт раскладку
|
||||
|
||||
- **GIVEN** загрузка в `review` с эффективным планом
|
||||
- **WHEN** пользователь выбирает «Применить»
|
||||
- **THEN** создаются хардлинки по плану, задача переходит к раскладке
|
||||
|
||||
#### Scenario: Отклонить и привязать заново
|
||||
|
||||
- **GIVEN** загрузка в `review`
|
||||
- **WHEN** пользователь «Отклонить», затем «Привязать заново»
|
||||
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
|
||||
подтверждением (авто-раскладка не делается)
|
||||
|
||||
### Requirement: Подсказка мягкая, override жёсткий
|
||||
|
||||
Подсказка (`hint`) SHALL быть мягким сигналом — её интерпретирует LLM при
|
||||
перераспознавании. Ручная правка поля SHALL быть жёстким **override**: система
|
||||
берёт значение как есть и «пиннит» его; перераспознавание НЕ SHALL затирать уже
|
||||
поправленное поле. Накопленные подсказки и правки SHALL переживать
|
||||
перераспознавание и накладываться на новый план.
|
||||
|
||||
#### Scenario: Override переживает перераспознавание
|
||||
|
||||
- **GIVEN** пользователь зафиксировал тип `series` как override
|
||||
- **WHEN** запускается перераспознавание по новой подсказке
|
||||
- **THEN** в новом эффективном плане тип остаётся `series`
|
||||
|
||||
### Requirement: Единый список источников совпадения на ревью
|
||||
|
||||
Экран ревью (`/review/{id}`) SHALL показывать совпавшие источники **единым
|
||||
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
|
||||
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
|
||||
Ровно один источник в списке SHALL быть отмечен активным (эффективный
|
||||
матч). Экран SHALL позволять как операции над этим списком: выбрать
|
||||
кандидата базы, переключиться на другого кандидата и снять матч с базой
|
||||
обратно на нейронку («без базы»). Смена активного источника SHALL
|
||||
выполняться через раундтрип на сервер (форма/htmx), без клиентского
|
||||
пересчёта доменного состояния. Список источников SHALL показываться только
|
||||
при наличии плана распознавания.
|
||||
|
||||
#### Scenario: Нейронка — строка в общем списке
|
||||
|
||||
- **GIVEN** загрузка в `review` с распознаванием нейронкой и одним или
|
||||
несколькими кандидатами метабаз
|
||||
- **WHEN** пользователь открывает `GET /review/{id}`
|
||||
- **THEN** источники показаны единым списком, где строка «распознано
|
||||
нейронкой» стоит наравне с кандидатами баз
|
||||
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
|
||||
|
||||
#### Scenario: Переключение между кандидатами
|
||||
|
||||
- **GIVEN** на экране ревью выбран один кандидат метабазы
|
||||
- **WHEN** пользователь выбирает другого кандидата из списка
|
||||
- **THEN** активным становится выбранный кандидат, прочие — неактивны
|
||||
|
||||
#### Scenario: Снятие матча в пользу нейронки
|
||||
|
||||
- **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo»
|
||||
- **WHEN** пользователь выбирает строку «распознано нейронкой»
|
||||
- **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег
|
||||
папки провайдера не проставляется
|
||||
- **AND** поля источника — из распознавания нейронкой, без унаследованных
|
||||
от прежнего кандидата название/год
|
||||
|
||||
### Requirement: Ручное добавление источника по id или URL
|
||||
|
||||
Когда автопоиск по базам промахнулся, экран ревью SHALL позволять добавить
|
||||
источник вручную — по идентификатору записи метабазы или, где применимо, по
|
||||
её URL. Ввод SHALL разбираться и валидироваться в пару
|
||||
`(provider, provider_id)` на входной границе (`internal/httpapi`); допустимые
|
||||
провайдеры — `tmdb`, `tvdb`, `imdb`. Добавленный источник SHALL появляться в
|
||||
списке как выбираемая строка; при совпадении `provider:id` с уже присутствующим
|
||||
источником новая строка NOT создаётся, а выбирается существующая.
|
||||
Некорректный ввод SHALL отклоняться с сообщением, не меняя текущий активный
|
||||
источник.
|
||||
|
||||
#### Scenario: Добавление кандидата по URL TMDB
|
||||
|
||||
- **GIVEN** загрузка в `review`, где нужной записи нет среди автокандидатов
|
||||
- **WHEN** пользователь вводит URL записи TMDB и подтверждает добавление
|
||||
- **THEN** из URL извлекаются провайдер и id, источник добавляется в список
|
||||
выбираемой строкой
|
||||
|
||||
#### Scenario: Дубль id выбирает существующую строку
|
||||
|
||||
- **GIVEN** в списке уже есть кандидат с данным `provider:id`
|
||||
- **WHEN** пользователь добавляет вручную тот же `provider:id`
|
||||
- **THEN** новая строка не создаётся, активным становится существующий
|
||||
кандидат
|
||||
|
||||
#### Scenario: Некорректный ввод отклонён
|
||||
|
||||
- **WHEN** пользователь вводит нераспознаваемый id/URL
|
||||
- **THEN** экран показывает сообщение об ошибке и не меняет текущий активный
|
||||
источник
|
||||
|
||||
### Requirement: Предпросмотр полей источника до фиксации выбора
|
||||
|
||||
Экран ревью SHALL показывать для рассматриваемого источника (нейронка,
|
||||
кандидат базы или добавленный вручную) **поля** результата — тип, название,
|
||||
год, с зарезервированным местом под режиссёра. Показ полей источника
|
||||
MUST NOT менять сохранённый матч загрузки и MUST NOT создавать хардлинки:
|
||||
сохранённый матч меняется только явным выбором источника, а раскладка —
|
||||
только действием «Применить». Совпадение целевых путей предпросмотра с
|
||||
результатом применения регулируется требованием «Превью раскладки через
|
||||
единую логику именования» (`web-ui`).
|
||||
|
||||
#### Scenario: Предпросмотр полей без фиксации выбора
|
||||
|
||||
- **GIVEN** список источников на экране ревью
|
||||
- **WHEN** пользователь рассматривает источник, ещё не выбрав его активным
|
||||
- **THEN** показаны поля результата (тип, название, год) для этого источника
|
||||
- **AND** сохранённый матч загрузки не меняется, хардлинки не создаются
|
||||
|
||||
#### Scenario: Зарезервированное место под режиссёра
|
||||
|
||||
- **GIVEN** режиссёр из метабазы пока не загружается
|
||||
- **WHEN** отображается предпросмотр полей источника
|
||||
- **THEN** в предпросмотре присутствует место под режиссёра, показанное
|
||||
пустым (или прочерком), не ломая вёрстку
|
||||
|
||||
### Requirement: Разделение труда транспортов в ревью
|
||||
|
||||
Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL
|
||||
быть поверхностью точных правок (маппинг файлов, выбор/ввод источника,
|
||||
предпросмотр). Telegram SHALL давать быстрые действия (одобрить, подсказать,
|
||||
переключить тип, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом на ту же
|
||||
страницу; точечные правки, не помещающиеся в чат, SHALL делаться в вебе.
|
||||
|
||||
#### Scenario: Эскалация из Telegram в веб
|
||||
|
||||
- **GIVEN** загрузка в `review`, требующая точечного маппинга файлов
|
||||
- **WHEN** пользователь в Telegram выбирает «В вебе»
|
||||
- **THEN** бот даёт deep-link на страницу ревью той же загрузки
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Уведомление о рассинхроне
|
||||
|
||||
**Reason**: Уведомления автора — единое поведение, собранное в capability
|
||||
`notifications`; здесь оно дублировало эту заботу.
|
||||
**Migration**: Требование перенесено без изменений в `notifications` (см.
|
||||
`specs/notifications`). Сама сверка и переходы `orphaned`/`target_missing`
|
||||
остаются в `state-reconciliation`.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Единый список источников совпадения на ревью
|
||||
|
||||
**Reason**: Поведение ревью, а не оформление; выделено в capability `review`.
|
||||
**Migration**: Требование перенесено без изменений в `review` (см. `specs/review`).
|
||||
|
||||
### Requirement: Ручное добавление источника по id или URL
|
||||
|
||||
**Reason**: Действие ревью (ручной выбор источника), а не оформление UI.
|
||||
**Migration**: Требование перенесено без изменений в `review`.
|
||||
|
||||
### Requirement: Предпросмотр полей источника до фиксации выбора
|
||||
|
||||
**Reason**: Поведение ревью (предпросмотр источника до выбора), а не оформление.
|
||||
**Migration**: Требование перенесено без изменений в `review`; совпадение
|
||||
целевых путей превью по-прежнему регулируется требованием `web-ui` «Превью
|
||||
раскладки через единую логику именования».
|
||||
@@ -0,0 +1,56 @@
|
||||
## 1. Дельта-спеки change (готово при propose)
|
||||
|
||||
- [x] 1.1 `recognition` — REMOVED 4 требования метабазы + ADDED разбор LLM
|
||||
- [x] 1.2 `metadata-match` (new) — поиск/подтверждение матча + перенесённые требования
|
||||
- [x] 1.3 `review` (new) — процесс ревью + перенос 3 требований из web-ui
|
||||
- [x] 1.4 `file-layout` (new) — миграция jellyfin-layout.md
|
||||
- [x] 1.5 `download-tracking` (new) — миграция прямого пути FSM из workflow.md
|
||||
- [x] 1.6 `notifications` (new) — падения/дебаунс/пинги + перенос из state-reconciliation
|
||||
- [x] 1.7 `ingest` — ADDED ядро приёма + перенос 3 требований из identity
|
||||
- [x] 1.8 `identity`/`web-ui`/`state-reconciliation` — REMOVED-дельты переносов
|
||||
- [x] 1.9 `openspec validate --strict` — проходит
|
||||
|
||||
## 2. Ревью дизайна (чекпоинт до влития)
|
||||
|
||||
- [ ] 2.1 Проверить полноту переносов по таблицам design.md D2–D5: каждое
|
||||
исходное требование учтено (STAY либо REMOVED+ADDED), ни одно не потеряно
|
||||
- [ ] 2.2 Сверить счётчик требований до/после (сумма по капабилити не изменилась,
|
||||
кроме намеренно добавленного «Приём источника и заведение загрузки»)
|
||||
- [ ] 2.3 Подтвердить, что формулировки перенесены эквивалентно (нормативная сила
|
||||
SHALL/MUST и сценарии сохранены), поведение системы не меняется
|
||||
|
||||
## 3. Purpose живых спек (при/после archive)
|
||||
|
||||
- [ ] 3.1 Дописать `## Purpose` новым capability (`metadata-match`, `review`,
|
||||
`file-layout`, `download-tracking`, `notifications`) — иначе archive
|
||||
проставит «TBD», как у `live-status`
|
||||
- [ ] 3.2 Подчистить стухший `## Purpose` у доноров: `identity` (убрать
|
||||
инфохэши/дедуп/атомарный возврат), `recognition` (убрать сверку/локаль
|
||||
TMDB/сбор кандидатов — оставить разбор LLM), `state-reconciliation` (убрать
|
||||
«уведомления о рассинхроне»), `web-ui` (убрать ревью-специфику)
|
||||
|
||||
## 5. Синхронизация docs/specs (источник истины переезжает в OpenSpec)
|
||||
|
||||
- [ ] 5.1 `docs/specs/recognition.md` — шапка «источник истины: openspec/specs/
|
||||
recognition + metadata-match»; не дублировать содержимое
|
||||
- [ ] 5.2 `docs/specs/review-ux.md` — шапка «источник истины: openspec/specs/review»
|
||||
- [ ] 5.3 `docs/specs/jellyfin-layout.md` — шапка «источник истины: openspec/specs/
|
||||
file-layout»
|
||||
- [ ] 5.4 `docs/specs/workflow.md` — шапка «прямой путь FSM: openspec/specs/
|
||||
download-tracking; сверка: state-reconciliation; уведомления: notifications»
|
||||
- [ ] 5.5 `docs/specs/architecture.md` — сверить перечень capabilities и ссылки
|
||||
|
||||
## 6. Обновление беклога и памятки
|
||||
|
||||
- [ ] 6.1 `docs/backlog.md` — снять пункт «Пересмотр набора capabilities и
|
||||
рефакторинг спек»
|
||||
- [ ] 6.2 `CLAUDE.md` — при необходимости обновить перечисление capabilities
|
||||
(ingest, recognition, metadata-match, review, file-layout, download-tracking,
|
||||
notifications, state-reconciliation, live-status, web-ui, identity)
|
||||
|
||||
## 7. Влитие и архив
|
||||
|
||||
- [ ] 7.1 Ревью change до архива (процесс CLAUDE.md)
|
||||
- [ ] 7.2 `openspec archive refactor-capability-boundaries` — влить дельты в
|
||||
`openspec/specs/`
|
||||
- [ ] 7.3 Повторный `openspec validate --strict` по влитым спекам
|
||||
@@ -0,0 +1,93 @@
|
||||
# download-tracking Specification
|
||||
|
||||
## Purpose
|
||||
Отслеживание скачивания и прямой путь машины состояний загрузки: поллинг
|
||||
qBittorrent и сопоставление его состояний (downloading → completed; готовность
|
||||
только когда файлы на месте), таймауты-предохранители (`magnet_timeout`/
|
||||
`stuck_after`), ошибка qBit → failed, усыновление раздач по категории/тегу и
|
||||
переходы под per-download блокировкой. Сверка уже разложенного с реальностью —
|
||||
в `state-reconciliation`.
|
||||
## Requirements
|
||||
### Requirement: Поллинг qBittorrent и сопоставление состояний
|
||||
|
||||
Worker SHALL периодически (`worker.poll_interval`, дефолт 5 с) опрашивать
|
||||
qBittorrent и сопоставлять его состояния раздачи с состоянием загрузки в БД.
|
||||
Готовые к раскладке состояния (`uploading`/`stalledUP`/`pausedUP`/`stoppedUP`/
|
||||
`queuedUP`/`forcedUP`, с учётом различий имён между qBit v4 и v5) SHALL переводить
|
||||
загрузку в `completed`. Ещё качающиеся состояния (`downloading`/`stalledDL`/
|
||||
`metaDL`/…) SHALL оставлять её в `downloading`.
|
||||
|
||||
#### Scenario: Раздача завершилась
|
||||
|
||||
- **GIVEN** загрузка в `downloading`
|
||||
- **WHEN** qBittorrent сообщает состояние `stalledUP` и файлы на месте
|
||||
- **THEN** загрузка переходит в `completed`
|
||||
|
||||
### Requirement: Готовность только когда файлы на месте
|
||||
|
||||
Переходные состояния qBittorrent система SHALL трактовать как «ждём»
|
||||
(`moving`/`checkingUP`/`checkingResumeData`/`allocating`): оставаться в
|
||||
`downloading` и НЕ объявлять готовность, даже если выставлены флаги `UP`, пока
|
||||
qBit не завершит перенос/проверку. Финальные пути файлов система SHALL брать из
|
||||
API после завершения переноса.
|
||||
|
||||
#### Scenario: Ждём завершения переноса
|
||||
|
||||
- **GIVEN** загрузка, у которой qBittorrent в состоянии `moving`
|
||||
- **WHEN** идёт тик поллинга
|
||||
- **THEN** загрузка остаётся в `downloading`, готовность не объявляется
|
||||
|
||||
### Requirement: Таймауты-предохранители downloading
|
||||
|
||||
Система SHALL переводить `metaDL`/`forcedMetaDL` дольше `magnet_timeout` (дефолт
|
||||
`24h`, редкий предохранитель) в `failed` (`error_code` `magnet_timeout`), а
|
||||
`stalledDL` дольше `stuck_after` — в `stuck` (`error_code`
|
||||
`stalled`). Возраст система SHALL считать от времени добавления в qBittorrent
|
||||
(`added_on`), а не от создания задачи, чтобы базис переживал retry и усыновление.
|
||||
Долгий `metaDL` система НЕ SHALL убивать агрессивно (медленные трекеры — норма).
|
||||
|
||||
#### Scenario: Завис на метаданных дольше таймаута
|
||||
|
||||
- **GIVEN** раздача в `metaDL` дольше `magnet_timeout` от `added_on`
|
||||
- **WHEN** идёт тик поллинга
|
||||
- **THEN** загрузка переходит в `failed` с `error_code` `magnet_timeout`
|
||||
|
||||
### Requirement: Ошибка qBittorrent переводит в failed
|
||||
|
||||
Состояния `error`/`missingFiles` система SHALL трактовать как настоящий провал и
|
||||
переводить загрузку в `failed` (`error_code` `qbit_error`) — в отличие от
|
||||
таймаутов-предохранителей, такой провал сверкой не воскрешается.
|
||||
|
||||
#### Scenario: qBit сообщает об ошибке
|
||||
|
||||
- **GIVEN** раздача в состоянии `missingFiles`
|
||||
- **WHEN** идёт тик поллинга
|
||||
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_error`
|
||||
|
||||
### Requirement: Усыновление раздач по категории или тегу
|
||||
|
||||
Worker SHALL периодически сверять раздачи qBittorrent с БД и **усыновлять** те, у
|
||||
которых наша категория (`qbittorrent.category`) ИЛИ тег (`qbittorrent.tag`), а
|
||||
записи в БД ещё нет, заводя для них загрузку в состоянии `downloading`. Категория
|
||||
ставится на добавляемые нами раздачи (push); тег позволяет подхватить уже
|
||||
существующую раздачу (pull), не трогая её категорию и файлы.
|
||||
|
||||
#### Scenario: Подхват существующей раздачи по тегу
|
||||
|
||||
- **GIVEN** в qBittorrent есть раздача с тегом `qbittorrent.tag`, которой нет в БД
|
||||
- **WHEN** worker сверяет qBittorrent с БД
|
||||
- **THEN** для раздачи заводится загрузка в состоянии `downloading`
|
||||
|
||||
### Requirement: Переходы состояний под per-download блокировкой
|
||||
|
||||
Все переходы состояний загрузки SHALL проходить через worker под per-download
|
||||
блокировкой, чтобы два транспорта не гонялись за одно состояние. Состояние SHALL
|
||||
быть персистентным в SQLite; активность загрузки SHALL выводиться только из
|
||||
`state`, без отдельного флага.
|
||||
|
||||
#### Scenario: Команды сериализуются
|
||||
|
||||
- **GIVEN** две одновременные команды к одной загрузке из разных транспортов
|
||||
- **WHEN** они обрабатываются
|
||||
- **THEN** переходы применяются последовательно под блокировкой, без гонки
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# file-layout Specification
|
||||
|
||||
## Purpose
|
||||
Раскладка распознанных файлов хардлинками под библиотеку Jellyfin: целевые
|
||||
имена фильмов и сериалов (папка/файл, provider-id, сезоны), сопоставление
|
||||
источник→цель, санитизация пути и запрет выхода за библиотеку, never-overwrite
|
||||
(коллизия → review) и copy-fallback при невозможности хардлинка. Владение
|
||||
целевым путём (`superseded`) и безопасный `Undo` (`nlink<=1`) —
|
||||
в `state-reconciliation`.
|
||||
## Requirements
|
||||
### Requirement: Целевые имена фильмов
|
||||
|
||||
Фильм система SHALL раскладывать в папку и файл вида `Название (Год)`, помещённые
|
||||
под `paths.movies`. При подтверждённом матче в базе имя папки SHALL нести
|
||||
provider-id (`[tmdbid-…]`/`[tvdbid-…]`) — он снимает неоднозначность русских
|
||||
названий для Jellyfin. Внешние субтитры SHALL именоваться `Имя.<lang>[.flag].srt`
|
||||
(флаги `forced`/`sdh`/`default`/`hi`), с базой имени, совпадающей с именем
|
||||
видеофайла; пары VobSub — `.idx` + `.sub`.
|
||||
|
||||
#### Scenario: Фильм с provider-id
|
||||
|
||||
- **GIVEN** распознанный фильм «Дюна Часть вторая» (2024) с матчем TMDB `693134`
|
||||
- **WHEN** строится целевой путь
|
||||
- **THEN** папка = `movies/Дюна Часть вторая (2024) [tmdbid-693134]/`
|
||||
- **AND** видеофайл = `Дюна Часть вторая (2024).mkv`
|
||||
|
||||
### Requirement: Целевые имена сериалов
|
||||
|
||||
Сериал система SHALL раскладывать под `paths.series` в папку `Название (Год)` с
|
||||
provider-id на папке сериала, сезонными подпапками `Season xx` и файлами вида
|
||||
`Название (Год) SxxEyy`.
|
||||
|
||||
#### Scenario: Серия сезона
|
||||
|
||||
- **GIVEN** распознанный сериал «Фарго» (2024) с матчем TVDB `123456`, серия S01E02
|
||||
- **WHEN** строится целевой путь
|
||||
- **THEN** путь = `series/Фарго (2024) [tvdbid-123456]/Season 01/Фарго (2024) S01E02.mkv`
|
||||
|
||||
### Requirement: Сопоставление источник → цель хардлинками
|
||||
|
||||
Для каждого распознанного **файла** (не каталога) система SHALL создавать
|
||||
**хардлинк** в `paths.movies`/`paths.series`; исходный путь берётся из
|
||||
qBittorrent (`save_path` + относительное имя файла из `/torrents/files`, уже
|
||||
включающее корневую папку многофайловой раздачи). Целевые каталоги SHALL
|
||||
создаваться `mkdir` (0755, `1000:1000`). Исходный файл система НЕ SHALL трогать —
|
||||
раздача продолжается, inode общий, диск не дублируется.
|
||||
|
||||
#### Scenario: Хардлинк не дублирует данные
|
||||
|
||||
- **GIVEN** видеофайл раздачи под `paths.downloads`
|
||||
- **WHEN** файл раскладывается
|
||||
- **THEN** в библиотеке создаётся хардлинк на тот же inode
|
||||
- **AND** исходный файл остаётся на месте
|
||||
|
||||
### Requirement: Санитизация целевого пути и запрет выхода за библиотеку
|
||||
|
||||
Целевое имя система SHALL санитизировать (без разделителей пути, `..`,
|
||||
управляющих символов), а финальный путь SHALL проверять на строгое нахождение под
|
||||
`paths.movies`/`paths.series`. Путь, выходящий за пределы библиотеки, система НЕ
|
||||
SHALL создавать. Безопасность SHALL держаться на валидации пути, а не на доверии к
|
||||
выходу LLM.
|
||||
|
||||
#### Scenario: Traversal отклоняется
|
||||
|
||||
- **GIVEN** распознанное имя, содержащее `../`
|
||||
- **WHEN** строится и проверяется целевой путь
|
||||
- **THEN** путь отклоняется как выходящий за пределы библиотеки, хардлинк не создаётся
|
||||
|
||||
### Requirement: Существующую цель не перезаписываем
|
||||
|
||||
Существующий целевой файл система НЕ SHALL перезаписывать. Если по целевому пути
|
||||
уже лежит тот же inode — операция идемпотентна (готово); если другой файл —
|
||||
это коллизия, и задача SHALL уходить в `review`.
|
||||
|
||||
#### Scenario: Коллизия уходит в review
|
||||
|
||||
- **GIVEN** по целевому пути уже лежит другой файл
|
||||
- **WHEN** выполняется раскладка
|
||||
- **THEN** файл не перезаписывается, задача переходит в `review` с причиной коллизии
|
||||
|
||||
### Requirement: Copy-fallback при невозможности хардлинка
|
||||
|
||||
Система SHALL при невозможности хардлинка (разные ФС или ФС без поддержки жёстких
|
||||
ссылок) НЕ падать, а копировать файл с предупреждением в лог, помечая ссылку
|
||||
статусом `copied`.
|
||||
|
||||
#### Scenario: Разные ФС — копирование
|
||||
|
||||
- **GIVEN** целевой и исходный каталоги на разных ФС
|
||||
- **WHEN** выполняется раскладка файла
|
||||
- **THEN** файл копируется, ссылка получает статус `copied`, в лог пишется предупреждение
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
## Purpose
|
||||
|
||||
Как система идентифицирует сущности домена: ULID-ключи (канонический
|
||||
lowercase-вид, нормализация и валидация на входных границах), множество
|
||||
инфохэшей загрузки (`download_infohash`), инвариант «не более одной
|
||||
активной загрузки на infohash» (дедупликация приёма, атомарный возврат в
|
||||
активное состояние), корреляция сущностей в логах по id.
|
||||
|
||||
lowercase-вид, нормализация и валидация на входных границах) и корреляция
|
||||
сущностей в логах по id. Инфохэши загрузки (`download_infohash`),
|
||||
дедупликация приёма и инвариант «не более одной активной загрузки на
|
||||
infohash» (атомарный возврат в активное состояние) — в capability `ingest`.
|
||||
## Requirements
|
||||
|
||||
### Requirement: ULID как первичный ключ сущностей
|
||||
|
||||
Каждая сущность домена SHALL иметь первичный ключ ULID — TEXT, 26 символов
|
||||
@@ -50,81 +48,6 @@ URL `/download/{id}`, параметры форм и команд. Синтак
|
||||
- **WHEN** клиент открывает `/download/abc!!!`
|
||||
- **THEN** ответ — 404, запрос к БД не выполняется
|
||||
|
||||
### Requirement: Множество инфохэшей загрузки
|
||||
|
||||
Загрузка SHALL иметь одну или более записей инфохэша (`download_infohash`:
|
||||
`infohash` lowercase hex, `kind` ∈ `v1`|`v2`). При приёме magnet-ссылки
|
||||
SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и
|
||||
btih (v1), и btmh (v2); `kind` определяется по длине hex (40 — `v1`, 64 —
|
||||
`v2`). Когда qBittorrent сообщает для раздачи оба хеша (`infohash_v1`,
|
||||
`infohash_v2`), система SHALL дописывать недостающие записи загрузке;
|
||||
усечённый хеш v2-only раздачи (поле `hash` qBittorrent, 40 hex от v2)
|
||||
записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой
|
||||
(поллинг, discover) SHALL выполняться по любому из известных хешей. Один и
|
||||
тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный
|
||||
приём после терминального состояния), но активной из них MUST быть не более
|
||||
одной.
|
||||
|
||||
#### Scenario: Гибридный торрент раскрывает оба хеша
|
||||
|
||||
- **GIVEN** загрузка принята по magnet с v1-хешем
|
||||
- **WHEN** qBittorrent отдаёт раздачу с заполненными `infohash_v1` и
|
||||
`infohash_v2`
|
||||
- **THEN** у загрузки появляются обе записи (`kind` = `v1` и `v2`)
|
||||
|
||||
#### Scenario: Сопоставление по v2-хешу
|
||||
|
||||
- **GIVEN** загрузка с записями v1- и v2-хешей
|
||||
- **WHEN** поллинг находит раздачу, совпавшую только по v2-хешу
|
||||
- **THEN** раздача сопоставляется с этой загрузкой
|
||||
|
||||
### Requirement: Дедупликация приёма по любому из хешей
|
||||
|
||||
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
|
||||
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
|
||||
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
|
||||
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
|
||||
более одной активной загрузки на infohash». Отдельного снимаемого/
|
||||
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
|
||||
выводится только из `state`.
|
||||
|
||||
#### Scenario: Повторный приём при активной загрузке
|
||||
|
||||
- **GIVEN** активная загрузка с infohash `h`
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
|
||||
#### Scenario: Повторный приём после завершения
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
|
||||
|
||||
### Requirement: Атомарность возврата загрузки в активное состояние
|
||||
|
||||
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
|
||||
возвращающем загрузку из терминального состояния в активное (ручной retry,
|
||||
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
|
||||
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
|
||||
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
|
||||
сохраняя инвариант «не более одной активной загрузки на infohash».
|
||||
Отказ SHALL происходить до побочных эффектов во внешних системах
|
||||
(повторного добавления торрента в qBittorrent).
|
||||
|
||||
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
|
||||
гибридного торрента): хеш, которым владеет другая активная загрузка,
|
||||
дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное
|
||||
состояние в обход этой проверки SHALL отклоняться хранилищем (механический
|
||||
бэкстоп вместо удалённого unique-индекса).
|
||||
|
||||
#### Scenario: Retry при занятом хеше
|
||||
|
||||
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
|
||||
#2 с тем же `h`
|
||||
- **WHEN** пользователь вызывает retry для #1
|
||||
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
|
||||
- **AND** активной по `h` остаётся #2
|
||||
|
||||
### Requirement: Корреляция сущностей в логах
|
||||
|
||||
Записи журнала, относящиеся к сущности, SHALL содержать её id в атрибуте
|
||||
@@ -159,3 +82,4 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
|
||||
- **AND** порядок загрузок по `id` совпадает с порядком по `created_at`
|
||||
- **AND** каждый прежний `infohash` представлен записью в
|
||||
`download_infohash`
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Приём загрузки: использование контекста для отображаемого имени торрента в
|
||||
qBittorrent. Capability описывает вывод человекочитаемого имени из контекста
|
||||
(через LLM или алгоритмический фолбек) и его передачу в qBittorrent.
|
||||
|
||||
Приём загрузки — единый use-case для всех транспортов (HTTP, Telegram, CLI):
|
||||
парс источника (Ф1 — magnet), извлечение инфохэшей (`download_infohash`),
|
||||
дедупликация по активной задаче, атомарное заведение `download` и отдача
|
||||
источника в qBittorrent, а также вывод человекочитаемого отображаемого имени из
|
||||
контекста (через LLM или алгоритмический фолбек). Держит инвариант «не более
|
||||
одной активной загрузки на infohash» (атомарный возврат в активное состояние).
|
||||
## Requirements
|
||||
|
||||
### Requirement: Отображаемое имя торрента из контекста
|
||||
|
||||
При добавлении загрузки в qBittorrent система SHALL выводить из контекста
|
||||
@@ -107,3 +108,105 @@ JSON-вывод), извлекая из контекста тип (movie/series)
|
||||
|
||||
- **WHEN** ни LLM, ни алгоритмический фолбек не дали непустого имени
|
||||
- **THEN** система добавляет загрузку без параметра `rename`
|
||||
|
||||
### Requirement: Приём источника и заведение загрузки
|
||||
|
||||
Приём SHALL быть единым use-case, общим для всех транспортов (HTTP, Telegram,
|
||||
CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL извлечь
|
||||
инфохэши, дедуплицировать по активной задаче, при отсутствии дубля завести
|
||||
загрузку (`download` в состоянии `downloading` + записи `download_infohash`) и
|
||||
отдать источник в qBittorrent (категория `qbittorrent.category`, savepath). Если
|
||||
добавление в qBittorrent не удалось, система SHALL перевести уже заведённую
|
||||
загрузку в `failed` (`error_code` `qbit_add`) и уведомить автора. Заведение
|
||||
загрузки и запись её хешей SHALL выполняться атомарно (см. «Атомарность возврата
|
||||
загрузки в активное состояние»).
|
||||
|
||||
#### Scenario: Успешный приём magnet
|
||||
|
||||
- **GIVEN** валидная magnet-ссылка и контекст
|
||||
- **WHEN** вызывается приём
|
||||
- **THEN** создаётся `download` в `downloading` с записями `download_infohash`
|
||||
- **AND** источник отдан в qBittorrent с нашей категорией
|
||||
|
||||
#### Scenario: Падение добавления в qBittorrent
|
||||
|
||||
- **GIVEN** заведённую загрузку не удалось добавить в qBittorrent
|
||||
- **WHEN** обрабатывается ошибка добавления
|
||||
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add`
|
||||
- **AND** автор загрузки уведомляется
|
||||
|
||||
### Requirement: Множество инфохэшей загрузки
|
||||
|
||||
Загрузка SHALL иметь одну или более записей инфохэша (`download_infohash`:
|
||||
`infohash` lowercase hex, `kind` ∈ `v1`|`v2`). При приёме magnet-ссылки
|
||||
SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и
|
||||
btih (v1), и btmh (v2); `kind` определяется по длине hex (40 — `v1`, 64 —
|
||||
`v2`). Когда qBittorrent сообщает для раздачи оба хеша (`infohash_v1`,
|
||||
`infohash_v2`), система SHALL дописывать недостающие записи загрузке;
|
||||
усечённый хеш v2-only раздачи (поле `hash` qBittorrent, 40 hex от v2)
|
||||
записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой
|
||||
(поллинг, discover) SHALL выполняться по любому из известных хешей. Один и
|
||||
тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный
|
||||
приём после терминального состояния), но активной из них MUST быть не более
|
||||
одной.
|
||||
|
||||
#### Scenario: Гибридный торрент раскрывает оба хеша
|
||||
|
||||
- **GIVEN** загрузка принята по magnet с v1-хешем
|
||||
- **WHEN** qBittorrent отдаёт раздачу с заполненными `infohash_v1` и
|
||||
`infohash_v2`
|
||||
- **THEN** у загрузки появляются обе записи (`kind` = `v1` и `v2`)
|
||||
|
||||
#### Scenario: Сопоставление по v2-хешу
|
||||
|
||||
- **GIVEN** загрузка с записями v1- и v2-хешей
|
||||
- **WHEN** поллинг находит раздачу, совпавшую только по v2-хешу
|
||||
- **THEN** раздача сопоставляется с этой загрузкой
|
||||
|
||||
### Requirement: Дедупликация приёма по любому из хешей
|
||||
|
||||
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
|
||||
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
|
||||
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
|
||||
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
|
||||
более одной активной загрузки на infohash». Отдельного снимаемого/
|
||||
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
|
||||
выводится только из `state`.
|
||||
|
||||
#### Scenario: Повторный приём при активной загрузке
|
||||
|
||||
- **GIVEN** активная загрузка с infohash `h`
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
|
||||
#### Scenario: Повторный приём после завершения
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
|
||||
|
||||
### Requirement: Атомарность возврата загрузки в активное состояние
|
||||
|
||||
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
|
||||
возвращающем загрузку из терминального состояния в активное (ручной retry,
|
||||
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
|
||||
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
|
||||
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
|
||||
сохраняя инвариант «не более одной активной загрузки на infohash».
|
||||
Отказ SHALL происходить до побочных эффектов во внешних системах
|
||||
(повторного добавления торрента в qBittorrent).
|
||||
|
||||
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
|
||||
гибридного торрента): хеш, которым владеет другая активная загрузка,
|
||||
дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное
|
||||
состояние в обход этой проверки SHALL отклоняться хранилищем (механический
|
||||
бэкстоп вместо удалённого unique-индекса).
|
||||
|
||||
#### Scenario: Retry при занятом хеше
|
||||
|
||||
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
|
||||
#2 с тем же `h`
|
||||
- **WHEN** пользователь вызывает retry для #1
|
||||
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
|
||||
- **AND** активной по `h` остаётся #2
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# metadata-match Specification
|
||||
|
||||
## Purpose
|
||||
Сверка распознанного плана с внешними базами метаданных (TMDB/TVDB/TVMaze):
|
||||
поиск записи по нескольким названиям с нормализацией, локаль запроса,
|
||||
подтверждение единичного сильного матча (официальный `provider_id` +
|
||||
каноническое имя/год) и сбор кандидатов с URL для ручного выбора в `review`.
|
||||
Разбор сигналов моделью — в `recognition`.
|
||||
## Requirements
|
||||
### Requirement: Сверка с базой по нескольким названиям
|
||||
|
||||
При сверке плана с включёнными базами метаданных система SHALL искать по
|
||||
нескольким названиям в порядке убывания силы ключа: сначала по
|
||||
`original_title`, затем по локализованному `title`, затем по `provider_hint`.
|
||||
Поиск SHALL останавливаться, как только очередной запрос дал единичный
|
||||
сильный матч (ровно один кандидат с совпадением названия и года). Запрос с
|
||||
названием, нормализованно совпадающим с уже выполненным, система SHALL
|
||||
пропускать, чтобы не обращаться к базе повторно с тем же ключом.
|
||||
|
||||
Кандидаты для ручного выбора в review система SHALL собирать из всех
|
||||
выполненных заходов с дедупликацией по `provider:id` и общим потолком.
|
||||
|
||||
#### Scenario: Иностранный фильм находится по оригинальному названию
|
||||
|
||||
- **GIVEN** план с `title` «Тёмный рыцарь», `original_title` «The Dark Knight», год 2008
|
||||
- **WHEN** выполняется сверка с базой
|
||||
- **THEN** первый запрос идёт по «The Dark Knight»
|
||||
- **AND** при единичном сильном матче дальнейшие запросы (по `title`, `provider_hint`) не выполняются
|
||||
|
||||
#### Scenario: Фолбэк на локализованное название
|
||||
|
||||
- **GIVEN** план, для которого запрос по `original_title` не дал единичного сильного матча
|
||||
- **WHEN** продолжается сверка
|
||||
- **THEN** выполняется запрос по локализованному `title`
|
||||
- **AND** при отсутствии матча и там — запрос по `provider_hint`
|
||||
|
||||
#### Scenario: Дублирующий запрос пропускается
|
||||
|
||||
- **GIVEN** план, у которого `original_title` нормализованно совпадает с `title`
|
||||
- **WHEN** выполняется сверка
|
||||
- **THEN** база запрашивается этим названием один раз, повторный заход по `title` не делается
|
||||
|
||||
### Requirement: Подтверждение матча и каноническое имя
|
||||
|
||||
При единичном сильном матче система SHALL брать из записи базы официальный
|
||||
`provider` (`tmdb`|`tvdb`|`tvmaze`) и `provider_id`, а также каноническое название
|
||||
и год, и подменять ими соответствующие поля плана (для сериала — с учётом внешнего
|
||||
тега TVDB/IMDb из `externals`, идущего в имя папки). Матч SHALL считаться
|
||||
подтверждённым только при ровно одном сильном кандидате; при нуле или нескольких
|
||||
кандидатах подтверждённого матча быть SHALL NOT (авто-раскладка не разрешается,
|
||||
кандидаты уходят в review). Работа с базами опциональна: при выключенных базах
|
||||
сверка не выполняется и подтверждённого матча нет.
|
||||
|
||||
#### Scenario: Единичный матч даёт id и каноническое имя
|
||||
|
||||
- **GIVEN** поиск вернул ровно одного сильного кандидата TMDB для фильма
|
||||
- **WHEN** матч подтверждается
|
||||
- **THEN** план получает `provider`=`tmdb`, `provider_id`, каноническое название и год
|
||||
|
||||
#### Scenario: Несколько кандидатов — матч не подтверждён
|
||||
|
||||
- **GIVEN** поиск вернул более одного подходящего кандидата
|
||||
- **WHEN** оценивается матч
|
||||
- **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review
|
||||
|
||||
### Requirement: Локаль запроса к TMDB
|
||||
|
||||
Запрос поиска к TMDB SHALL передавать параметр `language`, по умолчанию
|
||||
`ru-RU`, со значением, настраиваемым конфигом `[metadata.tmdb].language`.
|
||||
Это влияет только на локализованное поле `Title`/`Name`; поле
|
||||
`original_title`/`original_name` остаётся на языке оригинала, поэтому
|
||||
оригинальная сторона сравнения не затрагивается.
|
||||
|
||||
#### Scenario: Локализованный заголовок приходит по-русски
|
||||
|
||||
- **GIVEN** TMDB включён, `language` не задан в конфиге
|
||||
- **WHEN** выполняется поиск фильма с русской локализацией
|
||||
- **THEN** запрос содержит `language=ru-RU`
|
||||
- **AND** в кандидате `Title` приходит на русском, а `OriginalTitle` — на языке оригинала
|
||||
|
||||
### Requirement: Нормализация названий при сравнении
|
||||
|
||||
Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`,
|
||||
чтобы написания, различающиеся только `ё`/`е`, считались одним названием.
|
||||
|
||||
#### Scenario: «Тёмный» и «Темный» совпадают
|
||||
|
||||
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
|
||||
- **WHEN** сравниваются нормализованные названия
|
||||
- **THEN** они считаются совпадающими
|
||||
|
||||
### Requirement: Кандидат несёт URL для внешней проверки
|
||||
|
||||
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
|
||||
поле `URL` — ссылку на страницу элемента (фильма/сериала) на сайте
|
||||
провайдера. URL SHALL формироваться клиентом провайдера при поиске
|
||||
(`Search`) и сохраняться в таблице `metadata_candidate`. Отображение этой
|
||||
ссылки на экране ревью — забота `review`/`web-ui`, не данного требования.
|
||||
|
||||
Формат URL для каждого провайдера:
|
||||
|
||||
- **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или
|
||||
`https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из
|
||||
запроса `Query.Type`
|
||||
- **TVDB**: `https://www.thetvdb.com/dereferrer/series/{id}`
|
||||
- **TVMaze**: `https://www.tvmaze.com/shows/{id}` — URL SHALL использовать
|
||||
нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на
|
||||
TVMaze-страницу
|
||||
|
||||
#### Scenario: Кандидат TMDB с корректной ссылкой
|
||||
|
||||
- **GIVEN** TMDB найден кандидат-фильм с id `603` («Матрица»)
|
||||
- **WHEN** клиент TMDB формирует Candidate
|
||||
- **THEN** `URL` = `https://www.themoviedb.org/movie/603`
|
||||
|
||||
#### Scenario: Кандидат TVMaze с нативной ссылкой
|
||||
|
||||
- **GIVEN** TVMaze найден сериал с id `169` («Фарго»), внешний тег — TVDB id `269613`
|
||||
- **WHEN** клиент TVMaze формирует Candidate
|
||||
- **THEN** `URL` = `https://www.tvmaze.com/shows/169`
|
||||
- **AND** `TagProvider`/`TagID` остаются `tvdb`/`269613` (тег папки Jellyfin не меняется)
|
||||
|
||||
#### Scenario: URL сохраняется в БД
|
||||
|
||||
- **GIVEN** результат поиска с кандидатами
|
||||
- **WHEN** кандидаты сохраняются в таблицу `metadata_candidate`
|
||||
- **THEN** значение `url` SHALL быть записано в колонку `url`
|
||||
- **AND** при последующей загрузке данных ревью url доступен без повторной
|
||||
генерации
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# notifications Specification
|
||||
|
||||
## Purpose
|
||||
Уведомление автора загрузки о значимых событиях: падения (`failed`/`stuck`,
|
||||
включая приёмный `qbit_add` мимо поллинга) с дебаунсом повторов, приглашение в
|
||||
`review` и готовность, рассинхрон (`orphaned`/`target_missing`). Единое место
|
||||
доставки пингов, над которым транспорты (Telegram и др.) — тонкие адаптеры.
|
||||
## Requirements
|
||||
### Requirement: Уведомление о падении загрузки
|
||||
|
||||
Любой переход загрузки в `failed`/`stuck` система SHALL сопровождать уведомлением
|
||||
автора загрузки через настроенный механизм (`notifier`), чтобы падение не
|
||||
оставалось незамеченным. Это SHALL включать приёмное падение `qbit_add` (не
|
||||
удалось добавить раздачу в qBittorrent), которое идёт мимо поллинг-цикла worker.
|
||||
|
||||
#### Scenario: Уведомление при падении приёма
|
||||
|
||||
- **GIVEN** приём загрузки, где добавление в qBittorrent не удалось
|
||||
- **WHEN** загрузка помечается `failed` с `error_code` `qbit_add`
|
||||
- **THEN** автор загрузки получает уведомление о падении
|
||||
|
||||
### Requirement: Дебаунс повторных падений
|
||||
|
||||
Повторные падения одной задачи в пределах окна дебаунса система SHALL уведомлять
|
||||
лишь один раз, чтобы мерцающий stalled-торрент (`stuck` ↔ `downloading`) не спамил
|
||||
автора.
|
||||
|
||||
#### Scenario: Мерцающий stalled не спамит
|
||||
|
||||
- **GIVEN** задача, многократно переходящая `stuck` ↔ `downloading` в пределах окна дебаунса
|
||||
- **WHEN** происходят повторные падения
|
||||
- **THEN** уведомление отправляется один раз за окно
|
||||
|
||||
### Requirement: Пинг о входе в review и готовности
|
||||
|
||||
При переходе загрузки в `review` система SHALL пинговать автора (сообщение в
|
||||
Telegram / бейдж в вебе) — пользователя зовут, а не он опрашивает. После
|
||||
успешного применения (готовность) система SHALL показывать, что создано.
|
||||
|
||||
#### Scenario: Пинг при входе в review
|
||||
|
||||
- **GIVEN** загрузка переходит в `review`
|
||||
- **WHEN** происходит переход
|
||||
- **THEN** автор получает пинг с приглашением подтвердить раскладку
|
||||
|
||||
### Requirement: Уведомление о рассинхроне
|
||||
|
||||
При переходе задачи в `orphaned` или `target_missing` система SHALL
|
||||
уведомлять автора загрузки через настроенный механизм уведомлений
|
||||
(`notifier`), чтобы рассинхрон не оставался незамеченным.
|
||||
|
||||
#### Scenario: Уведомление при потере источника
|
||||
|
||||
- **WHEN** задача переходит в `orphaned`
|
||||
- **THEN** автор загрузки получает уведомление о рассинхроне
|
||||
|
||||
@@ -2,46 +2,12 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Распознавание: сопоставление загрузки с конкретным фильмом/сериалом во
|
||||
включённых базах метаданных. Capability описывает контракт LLM на названия,
|
||||
порядок и нормализацию сверки по нескольким названиям, локаль запроса к TMDB
|
||||
и сбор кандидатов для ручного выбора в review.
|
||||
|
||||
Распознавание: разбор недоверенных сигналов раздачи моделью в структурированный
|
||||
план (тип фильм/сериал, каноническое название и год, файлы → серии). Capability
|
||||
описывает пред-парс имени, контракт и провайдер LLM со структурированным
|
||||
выводом, роли файлов на краях и модель уверенности (решение auto/review). Сверка
|
||||
с внешними базами метаданных — в `metadata-match`.
|
||||
## Requirements
|
||||
|
||||
### Requirement: Сверка с базой по нескольким названиям
|
||||
|
||||
При сверке плана с включёнными базами метаданных система SHALL искать по
|
||||
нескольким названиям в порядке убывания силы ключа: сначала по
|
||||
`original_title`, затем по локализованному `title`, затем по `provider_hint`.
|
||||
Поиск SHALL останавливаться, как только очередной запрос дал единичный
|
||||
сильный матч (ровно один кандидат с совпадением названия и года). Запрос с
|
||||
названием, нормализованно совпадающим с уже выполненным, система SHALL
|
||||
пропускать, чтобы не обращаться к базе повторно с тем же ключом.
|
||||
|
||||
Кандидаты для ручного выбора в review система SHALL собирать из всех
|
||||
выполненных заходов с дедупликацией по `provider:id` и общим потолком.
|
||||
|
||||
#### Scenario: Иностранный фильм находится по оригинальному названию
|
||||
|
||||
- **GIVEN** план с `title` «Тёмный рыцарь», `original_title` «The Dark Knight», год 2008
|
||||
- **WHEN** выполняется сверка с базой
|
||||
- **THEN** первый запрос идёт по «The Dark Knight»
|
||||
- **AND** при единичном сильном матче дальнейшие запросы (по `title`, `provider_hint`) не выполняются
|
||||
|
||||
#### Scenario: Фолбэк на локализованное название
|
||||
|
||||
- **GIVEN** план, для которого запрос по `original_title` не дал единичного сильного матча
|
||||
- **WHEN** продолжается сверка
|
||||
- **THEN** выполняется запрос по локализованному `title`
|
||||
- **AND** при отсутствии матча и там — запрос по `provider_hint`
|
||||
|
||||
#### Scenario: Дублирующий запрос пропускается
|
||||
|
||||
- **GIVEN** план, у которого `original_title` нормализованно совпадает с `title`
|
||||
- **WHEN** выполняется сверка
|
||||
- **THEN** база запрашивается этим названием один раз, повторный заход по `title` не делается
|
||||
|
||||
### Requirement: Контракт LLM на оригинальное и локализованное названия
|
||||
|
||||
Промпт распознавания SHALL требовать от модели всегда заполнять и `title`, и
|
||||
@@ -67,78 +33,95 @@ gracefully использует доступные названия.
|
||||
- **THEN** разбор успешен без correction-ретрая
|
||||
- **AND** сверка использует `title` (и `provider_hint`)
|
||||
|
||||
### Requirement: Локаль запроса к TMDB
|
||||
### Requirement: Пред-парс имени релиза
|
||||
|
||||
Запрос поиска к TMDB SHALL передавать параметр `language`, по умолчанию
|
||||
`ru-RU`, со значением, настраиваемым конфигом `[metadata.tmdb].language`.
|
||||
Это влияет только на локализованное поле `Title`/`Name`; поле
|
||||
`original_title`/`original_name` остаётся на языке оригинала, поэтому
|
||||
оригинальная сторона сравнения не затрагивается.
|
||||
Перед вызовом LLM система SHALL выполнять дешёвый пред-парс имени торрента
|
||||
(`go-ptn`): извлекать черновые название, год, сезон, серию и качество. Результат
|
||||
пред-парса SHALL использоваться как вспомогательный сигнал в промпте и как
|
||||
сторона проверки согласованности при решении auto/review, но НЕ SHALL считаться
|
||||
итоговым распознаванием.
|
||||
|
||||
#### Scenario: Локализованный заголовок приходит по-русски
|
||||
#### Scenario: Пред-парс даёт черновые поля
|
||||
|
||||
- **GIVEN** TMDB включён, `language` не задан в конфиге
|
||||
- **WHEN** выполняется поиск фильма с русской локализацией
|
||||
- **THEN** запрос содержит `language=ru-RU`
|
||||
- **AND** в кандидате `Title` приходит на русском, а `OriginalTitle` — на языке оригинала
|
||||
- **WHEN** на вход распознавания поступает имя релиза `Fargo.S02.2015.WEB-DL.1080p`
|
||||
- **THEN** пред-парс возвращает черновые `title`, `year`, `season`, `quality`
|
||||
- **AND** эти значения передаются в промпт LLM как подсказка
|
||||
|
||||
### Requirement: Нормализация названий при сравнении
|
||||
### Requirement: Разбор сигналов LLM в структурированный план
|
||||
|
||||
Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`,
|
||||
чтобы написания, различающиеся только `ё`/`е`, считались одним названием.
|
||||
Система SHALL передавать LLM недоверенные сигналы (имя торрента, дерево файлов с
|
||||
размерами, текстовый контекст и накопленные подсказки, пред-парс) и получать
|
||||
структурированный план в схеме: `type` (`movie`|`series`), `title`,
|
||||
`original_title`, `year`, `provider_hint`, `files[]` и `confidence`. Каждый
|
||||
элемент `files[]` SHALL нести `src`, `role`
|
||||
(`main`|`episode`|`subtitle`|`extra`|`sample`|`ignore`) и, для сериала,
|
||||
per-file `season`/`episode` (отдельного скалярного `season` быть SHALL NOT — так
|
||||
выражаются мультисезонные паки и спецвыпуски). План SHALL приниматься только
|
||||
если каждый `files[].src` совпадает с реальным файлом торрента.
|
||||
|
||||
#### Scenario: «Тёмный» и «Темный» совпадают
|
||||
#### Scenario: План сериала с per-file нумерацией
|
||||
|
||||
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
|
||||
- **WHEN** сравниваются нормализованные названия
|
||||
- **THEN** они считаются совпадающими
|
||||
- **GIVEN** сезон-пак из 10 видеофайлов
|
||||
- **WHEN** LLM возвращает план
|
||||
- **THEN** `type` = `series`, а каждый видеофайл несёт свои `season`/`episode`
|
||||
|
||||
### Requirement: Кандидат несёт URL для внешней проверки
|
||||
#### Scenario: Несуществующий src отклоняется
|
||||
|
||||
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
|
||||
поле `URL` — ссылку на страницу элемента (фильма/сериала) на сайте
|
||||
провайдера. URL SHALL формироваться клиентом провайдера при поиске
|
||||
(`Search`) и сохраняться в таблице `metadata_candidate`. На странице ревью
|
||||
URL SHALL отображаться кликабельной ссылкой, открывающейся в новой вкладке
|
||||
браузера.
|
||||
- **GIVEN** ответ LLM, где `files[].src` не совпадает ни с одним файлом торрента
|
||||
- **WHEN** план разбирается
|
||||
- **THEN** такой план не принимается как валидный
|
||||
|
||||
Формат URL для каждого провайдера:
|
||||
### Requirement: Провайдер LLM за абстракцией со структурированным выводом
|
||||
|
||||
- **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или
|
||||
`https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из
|
||||
запроса `Query.Type`
|
||||
- **TVDB**: `https://www.thetvdb.com/dereferrer/series/{id}`
|
||||
- **TVMaze**: `https://www.tvmaze.com/shows/{id}` — URL SHALL использовать
|
||||
нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на
|
||||
TVMaze-страницу
|
||||
Доступ к LLM SHALL быть за интерфейсом с выбором реализации по полю `[llm].type`
|
||||
(первый тип — `openai-compat`). Система SHALL запрашивать JSON-режим
|
||||
(`response_format: {"type":"json_object"}`), срезать ```-ограждения и
|
||||
валидировать ответ в Go против схемы плана. При ошибке разбора система SHALL
|
||||
ретраить до `[llm].max_retries`, передавая модели саму ошибку и схему. Если после
|
||||
ретраев ответ не разобран, задача SHALL уходить в `review` (НЕ в `failed`) с
|
||||
причиной «ответ LLM не разобран».
|
||||
|
||||
#### Scenario: Кандидат TMDB с корректной ссылкой
|
||||
#### Scenario: Неразобранный ответ уходит в review
|
||||
|
||||
- **GIVEN** TMDB найден кандидат-фильм с id `603` («Матрица»)
|
||||
- **WHEN** клиент TMDB формирует Candidate
|
||||
- **THEN** `URL` = `https://www.themoviedb.org/movie/603`
|
||||
- **GIVEN** LLM, чей ответ не проходит валидацию схемы после всех ретраев
|
||||
- **WHEN** завершается распознавание
|
||||
- **THEN** задача переходит в `review` с причиной «ответ LLM не разобран»
|
||||
- **AND** задача НЕ переходит в `failed`
|
||||
|
||||
#### Scenario: Кандидат TVMaze с нативной ссылкой
|
||||
### Requirement: Модель уверенности и решение auto/review
|
||||
|
||||
- **GIVEN** TVMaze найден сериал с id `169` («Фарго»), внешний тег — TVDB id `269613`
|
||||
- **WHEN** клиент TVMaze формирует Candidate
|
||||
- **THEN** `URL` = `https://www.tvmaze.com/shows/169`
|
||||
- **AND** `TagProvider`/`TagID` остаются `tvdb`/`269613` (тег папки Jellyfin не меняется)
|
||||
Система SHALL раскладывать автоматически (без review) только при выполнении
|
||||
ВСЕГО: (1) подтверждённый единичный сильный матч в базе (`metadata-match`) с
|
||||
`provider_id`; (2) структурная валидация без предупреждений (фильм — ровно один
|
||||
основной видеофайл; сериал — число серий бьётся с базой, нумерация S·E
|
||||
консистентна); (3) согласованность пред-парса и LLM по типу/названию/году. Иначе
|
||||
задача SHALL уходить в `review` с явной причиной. Самооценку LLM (`confidence`)
|
||||
система SHALL учитывать лишь как вспомогательный сигнал, НЕ как единственный гейт.
|
||||
|
||||
#### Scenario: Ссылка в интерфейсе ревью
|
||||
#### Scenario: Нет матча в базе — всегда review
|
||||
|
||||
- **GIVEN** загрузка в состоянии `review` с кандидатами, у которых заполнен `url`
|
||||
- **WHEN** рендерится страница ревью
|
||||
- **THEN** в таблице кандидатов каждый кандидат SHALL отображаться со
|
||||
ссылкой на внешний сайт
|
||||
- **AND** ссылка открывается в новой вкладке (`target="_blank"`)
|
||||
- **AND** текстом ссылки служит провайдер или сокращённый url
|
||||
- **GIVEN** план без подтверждённого матча в базе (база выключена или матча нет)
|
||||
- **WHEN** принимается решение auto/review
|
||||
- **THEN** задача уходит в `review`, авто-раскладка не делается
|
||||
|
||||
#### Scenario: URL сохраняется в БД
|
||||
#### Scenario: Матч и чистая валидация — авто
|
||||
|
||||
- **GIVEN** результат поиска с кандидатами
|
||||
- **WHEN** кандидаты сохраняются в таблицу `metadata_candidate`
|
||||
- **THEN** значение `url` SHALL быть записано в колонку `url`
|
||||
- **AND** при последующей загрузке данных ревью url доступен без повторной
|
||||
генерации
|
||||
- **GIVEN** подтверждённый единичный матч, чистая структурная валидация и
|
||||
согласованность сигналов
|
||||
- **WHEN** принимается решение
|
||||
- **THEN** допускается авто-раскладка (при отсутствии `force_review`)
|
||||
|
||||
### Requirement: Роли файлов на краях раздачи
|
||||
|
||||
Система SHALL относить семплы, «экстра» и мусор к роли `ignore` (эвристики размер/
|
||||
имя + LLM), а внешние субтитры (`.srt`, `.ass`, пары VobSub `.idx`+`.sub`) —
|
||||
привязывать к соответствующему видео. Любую неоднозначность нумерации (дыры,
|
||||
дубли, спорные спецвыпуски) система SHALL эскалировать в `review`, а не разрешать
|
||||
молча.
|
||||
|
||||
#### Scenario: Семпл помечается ignore
|
||||
|
||||
- **GIVEN** раздача с файлом `sample.mkv` малого размера
|
||||
- **WHEN** строится план
|
||||
- **THEN** этот файл получает роль `ignore` и в раскладку не попадает
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# review Specification
|
||||
|
||||
## Purpose
|
||||
Ревью раскладки человеком после распознавания и матча: петля «догадка →
|
||||
подсказка → перераспознавание», команды (Применить/Уточнить/Распознать заново/
|
||||
Тип/Игнор/Позже/Отклонить/Undo/Привязать заново), мягкие подсказки vs жёсткие
|
||||
`override`, единый список источников совпадения с ручным добавлением и
|
||||
предпросмотром (превью = применение), разделение труда транспортов (веб —
|
||||
точные правки, Telegram — быстрые действия и эскалация в веб).
|
||||
## Requirements
|
||||
### Requirement: Вход в review с явной причиной
|
||||
|
||||
Когда модель уверенности не разрешает авто-раскладку, система SHALL переводить
|
||||
загрузку в `review` и SHALL показывать **конкретную причину** (низкая самооценка
|
||||
LLM; нет матча в базе или несколько кандидатов; предупреждение структурной
|
||||
валидации; неразобранный ответ LLM), а не обобщённое «не уверен». Поверхность
|
||||
решения SHALL быть единой для всех транспортов и содержать источник (имя, контекст,
|
||||
дерево файлов), догадку системы (тип, название, год, матч) и превью целевой
|
||||
раскладки.
|
||||
|
||||
#### Scenario: Причина видна в интерфейсе
|
||||
|
||||
- **GIVEN** загрузка ушла в `review` из-за отсутствия матча в базе
|
||||
- **WHEN** пользователь открывает ревью
|
||||
- **THEN** показана конкретная причина (напр. «нет в TMDB · уверенность 0.46»)
|
||||
|
||||
### Requirement: Команды ревью и их эффекты
|
||||
|
||||
Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по
|
||||
эффективному плану), **Уточнить** (добавить подсказку → перераспознать),
|
||||
**Распознать заново** (повторный прогон без новой подсказки), **Тип** (переключить
|
||||
movie↔series), **Игнор файла**, **Позже** (`deferred`), **Отклонить**
|
||||
(`cancelled`), **Undo** (снять созданные ссылки → `reverted`) и **Привязать
|
||||
заново** (из `reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
|
||||
подтверждением). Команды из любого транспорта SHALL сериализоваться worker'ом под
|
||||
per-download блокировкой; применяется последняя валидная команда. Команды,
|
||||
которым нужен источник, SHALL проверять его наличие синхронно перед действием.
|
||||
|
||||
#### Scenario: Применение создаёт раскладку
|
||||
|
||||
- **GIVEN** загрузка в `review` с эффективным планом
|
||||
- **WHEN** пользователь выбирает «Применить»
|
||||
- **THEN** создаются хардлинки по плану, задача переходит к раскладке
|
||||
|
||||
#### Scenario: Отклонить и привязать заново
|
||||
|
||||
- **GIVEN** загрузка в `review`
|
||||
- **WHEN** пользователь «Отклонить», затем «Привязать заново»
|
||||
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
|
||||
подтверждением (авто-раскладка не делается)
|
||||
|
||||
### Requirement: Подсказка мягкая, override жёсткий
|
||||
|
||||
Подсказка (`hint`) SHALL быть мягким сигналом — её интерпретирует LLM при
|
||||
перераспознавании. Ручная правка поля SHALL быть жёстким **override**: система
|
||||
берёт значение как есть и «пиннит» его; перераспознавание НЕ SHALL затирать уже
|
||||
поправленное поле. Накопленные подсказки и правки SHALL переживать
|
||||
перераспознавание и накладываться на новый план.
|
||||
|
||||
#### Scenario: Override переживает перераспознавание
|
||||
|
||||
- **GIVEN** пользователь зафиксировал тип `series` как override
|
||||
- **WHEN** запускается перераспознавание по новой подсказке
|
||||
- **THEN** в новом эффективном плане тип остаётся `series`
|
||||
|
||||
### Requirement: Единый список источников совпадения на ревью
|
||||
|
||||
Экран ревью (`/review/{id}`) SHALL показывать совпавшие источники **единым
|
||||
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
|
||||
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
|
||||
Ровно один источник в списке SHALL быть отмечен активным (эффективный
|
||||
матч). Экран SHALL позволять как операции над этим списком: выбрать
|
||||
кандидата базы, переключиться на другого кандидата и снять матч с базой
|
||||
обратно на нейронку («без базы»). Смена активного источника SHALL
|
||||
выполняться через раундтрип на сервер (форма/htmx), без клиентского
|
||||
пересчёта доменного состояния. Список источников SHALL показываться только
|
||||
при наличии плана распознавания.
|
||||
|
||||
#### Scenario: Нейронка — строка в общем списке
|
||||
|
||||
- **GIVEN** загрузка в `review` с распознаванием нейронкой и одним или
|
||||
несколькими кандидатами метабаз
|
||||
- **WHEN** пользователь открывает `GET /review/{id}`
|
||||
- **THEN** источники показаны единым списком, где строка «распознано
|
||||
нейронкой» стоит наравне с кандидатами баз
|
||||
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
|
||||
|
||||
#### Scenario: Переключение между кандидатами
|
||||
|
||||
- **GIVEN** на экране ревью выбран один кандидат метабазы
|
||||
- **WHEN** пользователь выбирает другого кандидата из списка
|
||||
- **THEN** активным становится выбранный кандидат, прочие — неактивны
|
||||
|
||||
#### Scenario: Снятие матча в пользу нейронки
|
||||
|
||||
- **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo»
|
||||
- **WHEN** пользователь выбирает строку «распознано нейронкой»
|
||||
- **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег
|
||||
папки провайдера не проставляется
|
||||
- **AND** поля источника — из распознавания нейронкой, без унаследованных
|
||||
от прежнего кандидата название/год
|
||||
|
||||
### Requirement: Ручное добавление источника по id или URL
|
||||
|
||||
Когда автопоиск по базам промахнулся, экран ревью SHALL позволять добавить
|
||||
источник вручную — по идентификатору записи метабазы или, где применимо, по
|
||||
её URL. Ввод SHALL разбираться и валидироваться в пару
|
||||
`(provider, provider_id)` на входной границе (`internal/httpapi`); допустимые
|
||||
провайдеры — `tmdb`, `tvdb`, `imdb`. Добавленный источник SHALL появляться в
|
||||
списке как выбираемая строка; при совпадении `provider:id` с уже присутствующим
|
||||
источником новая строка NOT создаётся, а выбирается существующая.
|
||||
Некорректный ввод SHALL отклоняться с сообщением, не меняя текущий активный
|
||||
источник.
|
||||
|
||||
#### Scenario: Добавление кандидата по URL TMDB
|
||||
|
||||
- **GIVEN** загрузка в `review`, где нужной записи нет среди автокандидатов
|
||||
- **WHEN** пользователь вводит URL записи TMDB и подтверждает добавление
|
||||
- **THEN** из URL извлекаются провайдер и id, источник добавляется в список
|
||||
выбираемой строкой
|
||||
|
||||
#### Scenario: Дубль id выбирает существующую строку
|
||||
|
||||
- **GIVEN** в списке уже есть кандидат с данным `provider:id`
|
||||
- **WHEN** пользователь добавляет вручную тот же `provider:id`
|
||||
- **THEN** новая строка не создаётся, активным становится существующий
|
||||
кандидат
|
||||
|
||||
#### Scenario: Некорректный ввод отклонён
|
||||
|
||||
- **WHEN** пользователь вводит нераспознаваемый id/URL
|
||||
- **THEN** экран показывает сообщение об ошибке и не меняет текущий активный
|
||||
источник
|
||||
|
||||
### Requirement: Предпросмотр полей источника до фиксации выбора
|
||||
|
||||
Экран ревью SHALL показывать для рассматриваемого источника (нейронка,
|
||||
кандидат базы или добавленный вручную) **поля** результата — тип, название,
|
||||
год, с зарезервированным местом под режиссёра. Показ полей источника
|
||||
MUST NOT менять сохранённый матч загрузки и MUST NOT создавать хардлинки:
|
||||
сохранённый матч меняется только явным выбором источника, а раскладка —
|
||||
только действием «Применить». Совпадение целевых путей предпросмотра с
|
||||
результатом применения регулируется требованием «Превью раскладки через
|
||||
единую логику именования» (`web-ui`).
|
||||
|
||||
#### Scenario: Предпросмотр полей без фиксации выбора
|
||||
|
||||
- **GIVEN** список источников на экране ревью
|
||||
- **WHEN** пользователь рассматривает источник, ещё не выбрав его активным
|
||||
- **THEN** показаны поля результата (тип, название, год) для этого источника
|
||||
- **AND** сохранённый матч загрузки не меняется, хардлинки не создаются
|
||||
|
||||
#### Scenario: Зарезервированное место под режиссёра
|
||||
|
||||
- **GIVEN** режиссёр из метабазы пока не загружается
|
||||
- **WHEN** отображается предпросмотр полей источника
|
||||
- **THEN** в предпросмотре присутствует место под режиссёра, показанное
|
||||
пустым (или прочерком), не ломая вёрстку
|
||||
|
||||
### Requirement: Разделение труда транспортов в ревью
|
||||
|
||||
Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL
|
||||
быть поверхностью точных правок (маппинг файлов, выбор/ввод источника,
|
||||
предпросмотр). Telegram SHALL давать быстрые действия (одобрить, подсказать,
|
||||
переключить тип, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом на ту же
|
||||
страницу; точечные правки, не помещающиеся в чат, SHALL делаться в вебе.
|
||||
|
||||
#### Scenario: Эскалация из Telegram в веб
|
||||
|
||||
- **GIVEN** загрузка в `review`, требующая точечного маппинга файлов
|
||||
- **WHEN** пользователь в Telegram выбирает «В вебе»
|
||||
- **THEN** бот даёт deep-link на страницу ревью той же загрузки
|
||||
|
||||
@@ -7,11 +7,10 @@ qBittorrent. Capability описывает периодическую и при
|
||||
присутствия **источника** (раздача в qBittorrent) и **цели** (разложенные
|
||||
хардлинки), вывод состояний рассинхрона (`target_missing`/`orphaned`/
|
||||
`deleted`) из матрицы «источник × цель», их переходы и самовосстановление,
|
||||
дебаунс пропажи источника, инвариант безопасного `Undo` (не снимать
|
||||
последнюю копию) и уведомления о рассинхроне.
|
||||
|
||||
дебаунс пропажи источника, владение целевым путём (один путь — один владелец)
|
||||
и инвариант безопасного `Undo` (не снимать последнюю копию). Уведомления о
|
||||
рассинхроне — в `notifications`.
|
||||
## Requirements
|
||||
|
||||
### Requirement: Периодическая сверка состояния с реальностью
|
||||
|
||||
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
|
||||
@@ -349,13 +348,3 @@ SHALL отклоняться сразу с пояснением, что исто
|
||||
`nlink > 1`
|
||||
- **THEN** система снимает целевой хардлинк, оставляя исходный файл нетронутым
|
||||
|
||||
### Requirement: Уведомление о рассинхроне
|
||||
|
||||
При переходе задачи в `orphaned` или `target_missing` система SHALL
|
||||
уведомлять автора загрузки через настроенный механизм уведомлений
|
||||
(`notifier`), чтобы рассинхрон не оставался незамеченным.
|
||||
|
||||
#### Scenario: Уведомление при потере источника
|
||||
|
||||
- **WHEN** задача переходит в `orphaned`
|
||||
- **THEN** система отправляет автору загрузки уведомление о потере источника
|
||||
|
||||
@@ -268,97 +268,3 @@ jellybit (`created_at`). Порядок MUST быть согласован ме
|
||||
- **WHEN** пользователь раскрывает спойлер переданного контекста
|
||||
- **THEN** контекст показывается нативным `<details>`, без скриптов
|
||||
|
||||
### Requirement: Единый список источников совпадения на ревью
|
||||
|
||||
Экран ревью (`/review/{id}`) SHALL показывать совпавшие источники **единым
|
||||
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
|
||||
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
|
||||
Ровно один источник в списке SHALL быть отмечен активным (эффективный
|
||||
матч). Экран SHALL позволять как операции над этим списком: выбрать
|
||||
кандидата базы, переключиться на другого кандидата и снять матч с базой
|
||||
обратно на нейронку («без базы»). Смена активного источника SHALL
|
||||
выполняться через раундтрип на сервер (форма/htmx), без клиентского
|
||||
пересчёта доменного состояния. Список источников SHALL показываться только
|
||||
при наличии плана распознавания.
|
||||
|
||||
#### Scenario: Нейронка — строка в общем списке
|
||||
|
||||
- **GIVEN** загрузка в `review` с распознаванием нейронкой и одним или
|
||||
несколькими кандидатами метабаз
|
||||
- **WHEN** пользователь открывает `GET /review/{id}`
|
||||
- **THEN** источники показаны единым списком, где строка «распознано
|
||||
нейронкой» стоит наравне с кандидатами баз
|
||||
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
|
||||
|
||||
#### Scenario: Переключение между кандидатами
|
||||
|
||||
- **GIVEN** на экране ревью выбран один кандидат метабазы
|
||||
- **WHEN** пользователь выбирает другого кандидата из списка
|
||||
- **THEN** активным становится выбранный кандидат, прочие — неактивны
|
||||
|
||||
#### Scenario: Снятие матча в пользу нейронки
|
||||
|
||||
- **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo»
|
||||
- **WHEN** пользователь выбирает строку «распознано нейронкой»
|
||||
- **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег
|
||||
папки провайдера не проставляется
|
||||
- **AND** поля источника — из распознавания нейронкой, без унаследованных
|
||||
от прежнего кандидата название/год
|
||||
|
||||
### Requirement: Ручное добавление источника по id или URL
|
||||
|
||||
Когда автопоиск по базам промахнулся, экран ревью SHALL позволять добавить
|
||||
источник вручную — по идентификатору записи метабазы или, где применимо, по
|
||||
её URL. Ввод SHALL разбираться и валидироваться в пару
|
||||
`(provider, provider_id)` на входной границе (`internal/httpapi`); допустимые
|
||||
провайдеры — `tmdb`, `tvdb`, `imdb`. Добавленный источник SHALL появляться в
|
||||
списке как выбираемая строка; при совпадении `provider:id` с уже присутствующим
|
||||
источником новая строка NOT создаётся, а выбирается существующая.
|
||||
Некорректный ввод SHALL отклоняться с сообщением, не меняя текущий активный
|
||||
источник.
|
||||
|
||||
#### Scenario: Добавление кандидата по URL TMDB
|
||||
|
||||
- **GIVEN** загрузка в `review`, где нужной записи нет среди автокандидатов
|
||||
- **WHEN** пользователь вводит URL записи TMDB и подтверждает добавление
|
||||
- **THEN** из URL извлекаются провайдер и id, источник добавляется в список
|
||||
выбираемой строкой
|
||||
|
||||
#### Scenario: Дубль id выбирает существующую строку
|
||||
|
||||
- **GIVEN** в списке уже есть кандидат с данным `provider:id`
|
||||
- **WHEN** пользователь добавляет вручную тот же `provider:id`
|
||||
- **THEN** новая строка не создаётся, активным становится существующий
|
||||
кандидат
|
||||
|
||||
#### Scenario: Некорректный ввод отклонён
|
||||
|
||||
- **WHEN** пользователь вводит нераспознаваемый id/URL
|
||||
- **THEN** экран показывает сообщение об ошибке и не меняет текущий активный
|
||||
источник
|
||||
|
||||
### Requirement: Предпросмотр полей источника до фиксации выбора
|
||||
|
||||
Экран ревью SHALL показывать для рассматриваемого источника (нейронка,
|
||||
кандидат базы или добавленный вручную) **поля** результата — тип, название,
|
||||
год, с зарезервированным местом под режиссёра. Показ полей источника
|
||||
MUST NOT менять сохранённый матч загрузки и MUST NOT создавать хардлинки:
|
||||
сохранённый матч меняется только явным выбором источника, а раскладка —
|
||||
только действием «Применить». Совпадение целевых путей предпросмотра с
|
||||
результатом применения регулируется требованием «Превью раскладки через
|
||||
единую логику именования».
|
||||
|
||||
#### Scenario: Предпросмотр полей без фиксации выбора
|
||||
|
||||
- **GIVEN** список источников на экране ревью
|
||||
- **WHEN** пользователь рассматривает источник, ещё не выбрав его активным
|
||||
- **THEN** показаны поля результата (тип, название, год) для этого источника
|
||||
- **AND** сохранённый матч загрузки не меняется, хардлинки не создаются
|
||||
|
||||
#### Scenario: Зарезервированное место под режиссёра
|
||||
|
||||
- **GIVEN** режиссёр из метабазы пока не загружается
|
||||
- **WHEN** отображается предпросмотр полей источника
|
||||
- **THEN** в предпросмотре присутствует место под режиссёра, показанное
|
||||
пустым (или прочерком), не ломая вёрстку
|
||||
|
||||
|
||||
Reference in New Issue
Block a user