From 512567c8ba822f077e49eaa4937c829d12f207c2 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 3 Jul 2026 21:17:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=20capabilities:=20=D1=86=D0=B5=D0=BF=D0=BE=D1=87=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=E2=86=92?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D1=87=E2=86=92=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?=E2=86=92=D1=80=D0=B0=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0?= =?UTF-8?q?=20(openspec)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Привёл набор 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) --- docs/backlog.md | 27 --- docs/specs/jellyfin-layout.md | 5 + docs/specs/recognition.md | 5 + docs/specs/review-ux.md | 4 + docs/specs/workflow.md | 7 + .../.openspec.yaml | 2 + .../design.md | 202 ++++++++++++++++++ .../proposal.md | 80 +++++++ .../specs/download-tracking/spec.md | 84 ++++++++ .../specs/file-layout/spec.md | 83 +++++++ .../specs/identity/spec.md | 19 ++ .../specs/ingest/spec.md | 102 +++++++++ .../specs/metadata-match/spec.md | 122 +++++++++++ .../specs/notifications/spec.md | 49 +++++ .../specs/recognition/spec.md | 118 ++++++++++ .../specs/review/spec.md | 164 ++++++++++++++ .../specs/state-reconciliation/spec.md | 9 + .../specs/web-ui/spec.md | 18 ++ .../tasks.md | 56 +++++ openspec/specs/download-tracking/spec.md | 93 ++++++++ openspec/specs/file-layout/spec.md | 92 ++++++++ openspec/specs/identity/spec.md | 86 +------- openspec/specs/ingest/spec.md | 113 +++++++++- openspec/specs/metadata-match/spec.md | 130 +++++++++++ openspec/specs/notifications/spec.md | 56 +++++ openspec/specs/recognition/spec.md | 171 +++++++-------- openspec/specs/review/spec.md | 173 +++++++++++++++ openspec/specs/state-reconciliation/spec.md | 17 +- openspec/specs/web-ui/spec.md | 94 -------- 29 files changed, 1866 insertions(+), 315 deletions(-) create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/design.md create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/proposal.md create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/download-tracking/spec.md create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/file-layout/spec.md create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/identity/spec.md create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/ingest/spec.md create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/metadata-match/spec.md create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/notifications/spec.md create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/recognition/spec.md create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/review/spec.md create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/state-reconciliation/spec.md create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/web-ui/spec.md create mode 100644 openspec/changes/archive/2026-07-03-refactor-capability-boundaries/tasks.md create mode 100644 openspec/specs/download-tracking/spec.md create mode 100644 openspec/specs/file-layout/spec.md create mode 100644 openspec/specs/metadata-match/spec.md create mode 100644 openspec/specs/notifications/spec.md create mode 100644 openspec/specs/review/spec.md diff --git a/docs/backlog.md b/docs/backlog.md index 7a288fe..681f0c2 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -165,33 +165,6 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект [docs/conventions](conventions/README.md), [«Словарь единого языка»](#словарь-единого-языка-ubiquitous-language). -### Пересмотр набора capabilities и рефакторинг спек - -Деление capabilities в OpenSpec сложилось по ходу миграции и смешивает -разные действия в одной спеке. Пример: `recognition` держит и разбор через -LLM, и **сверку с внешними базами** — а «поиск в базе» и «подтверждение -матча официальным id» суть разные действия, значит и разные capability. -Нужно пересмотреть набор и границы, чтобы имя capability отвечало одному -поведению: - -- `recognition` — только разбор сигналов через LLM (план, тип, название, - файлы → серии); -- отдельные capability под работу с метабазами: поиск записей во внешних - базах и сверку/подтверждение матча (разнести пока смешанное в - `recognition`); -- `web-ui` — только общее оформление, дизайн-система и общие компоненты - страниц; -- `review` — весь процесс ревью после распознавания и матча (мигрировать из - [review-ux.md](specs/review-ux.md); сейчас поведение ревью не в OpenSpec). - -Работа чисто по спекам (границы, RENAMED/MOVED requirements), код не -трогаем. Ценно тем, что снимает путаницу «какой capability трогать» на -каждой задаче. - -Связано: `CLAUDE.md` (SDD, миграция capabilities), openspec/specs -(`recognition`, `web-ui`), [review-ux.md](specs/review-ux.md), -[«Словарь единого языка»](#словарь-единого-языка-ubiquitous-language). - ### Сила совпадения кандидата и пересмотр распознавания/матчинга _(идея)_ Сейчас у кандидата метабазы нет метрики силы совпадения (`metadata_candidate` diff --git a/docs/specs/jellyfin-layout.md b/docs/specs/jellyfin-layout.md index 9301c7c..791a8f9 100644 --- a/docs/specs/jellyfin-layout.md +++ b/docs/specs/jellyfin-layout.md @@ -1,5 +1,10 @@ # Конвенции раскладки Jellyfin +> **Источник истины переехал в OpenSpec** — `openspec/specs/file-layout/` (имена, +> хардлинки, коллизия, copy-fallback). Владение путём (`superseded`) и безопасный +> undo (`nlink<=1`) — в `openspec/specs/state-reconciliation/`. Этот файл — +> справочный нарратив; при расхождении верна спека OpenSpec. + Целевые имена и структура, в которые jellybit раскладывает файлы хардлинками. Источники: [Movies](https://jellyfin.org/docs/general/server/media/movies), diff --git a/docs/specs/recognition.md b/docs/specs/recognition.md index 907e2bf..086ce8a 100644 --- a/docs/specs/recognition.md +++ b/docs/specs/recognition.md @@ -1,5 +1,10 @@ # Распознавание контента +> **Источник истины переехал в OpenSpec.** Актуальные требования — +> `openspec/specs/recognition/` (разбор сигналов LLM) и +> `openspec/specs/metadata-match/` (сверка с внешними базами). Этот файл остаётся +> справочным нарративом; при расхождении верна спека OpenSpec. + ## Задача По доступным сигналам определить: фильм или сериал; каноническое название diff --git a/docs/specs/review-ux.md b/docs/specs/review-ux.md index 96a1c85..5175cbf 100644 --- a/docs/specs/review-ux.md +++ b/docs/specs/review-ux.md @@ -1,5 +1,9 @@ # Ревью раскладки человеком +> **Источник истины переехал в OpenSpec** — `openspec/specs/review/`. Этот файл +> остаётся справочным нарративом (UI-макеты, разбор сценариев); при расхождении +> верна спека OpenSpec. + Что происходит, когда система не уверена в распознавании и не раскладывает файлы автоматически. Когда именно наступает ревью — см. [recognition.md](recognition.md); место состояния `review` в общем потоке — diff --git a/docs/specs/workflow.md b/docs/specs/workflow.md index 80c4c15..ed845f4 100644 --- a/docs/specs/workflow.md +++ b/docs/specs/workflow.md @@ -1,5 +1,12 @@ # Жизненный цикл загрузки и машина состояний +> **Источник истины переехал в OpenSpec.** Прямой путь FSM (downloading → +> completed → stuck/failed, поллинг, усыновление) — `openspec/specs/ +> download-tracking/`; сверка с реальностью — `openspec/specs/ +> state-reconciliation/`; уведомления — `openspec/specs/notifications/`. Этот +> файл — справочный нарратив по графу состояний; при расхождении верна спека +> OpenSpec. + Как загрузка проходит путь от приёма источника до разложенных файлов: состояния, переходы и то, что их вызывает. Кто владеет переходами и общее устройство — в [architecture.md](architecture.md); детали распознавания — diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/.openspec.yaml b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/design.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/design.md new file mode 100644 index 0000000..b35d288 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/design.md @@ -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/`»; содержимое не дублируем. +- **Граница 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. diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/proposal.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/proposal.md new file mode 100644 index 0000000..6a4a607 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/proposal.md @@ -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/`. diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/download-tracking/spec.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/download-tracking/spec.md new file mode 100644 index 0000000..392211c --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/download-tracking/spec.md @@ -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** переходы применяются последовательно под блокировкой, без гонки diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/file-layout/spec.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/file-layout/spec.md new file mode 100644 index 0000000..302534d --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/file-layout/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: Целевые имена фильмов + +Фильм система SHALL раскладывать в папку и файл вида `Название (Год)`, помещённые +под `paths.movies`. При подтверждённом матче в базе имя папки SHALL нести +provider-id (`[tmdbid-…]`/`[tvdbid-…]`) — он снимает неоднозначность русских +названий для Jellyfin. Внешние субтитры SHALL именоваться `Имя.[.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`, в лог пишется предупреждение diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/identity/spec.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/identity/spec.md new file mode 100644 index 0000000..0a63fc7 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/identity/spec.md @@ -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 на +него ссылаются. diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/ingest/spec.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/ingest/spec.md new file mode 100644 index 0000000..a54956e --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/ingest/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/metadata-match/spec.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/metadata-match/spec.md new file mode 100644 index 0000000..f70f64b --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/metadata-match/spec.md @@ -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 доступен без повторной + генерации diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/notifications/spec.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/notifications/spec.md new file mode 100644 index 0000000..c360017 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/notifications/spec.md @@ -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** автор загрузки получает уведомление о рассинхроне diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/recognition/spec.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/recognition/spec.md new file mode 100644 index 0000000..3265efc --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/recognition/spec.md @@ -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`. diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/review/spec.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/review/spec.md new file mode 100644 index 0000000..703fd73 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/review/spec.md @@ -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 на страницу ревью той же загрузки diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/state-reconciliation/spec.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/state-reconciliation/spec.md new file mode 100644 index 0000000..0a4cc5b --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/state-reconciliation/spec.md @@ -0,0 +1,9 @@ +## REMOVED Requirements + +### Requirement: Уведомление о рассинхроне + +**Reason**: Уведомления автора — единое поведение, собранное в capability +`notifications`; здесь оно дублировало эту заботу. +**Migration**: Требование перенесено без изменений в `notifications` (см. +`specs/notifications`). Сама сверка и переходы `orphaned`/`target_missing` +остаются в `state-reconciliation`. diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/web-ui/spec.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/web-ui/spec.md new file mode 100644 index 0000000..8b4b480 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/specs/web-ui/spec.md @@ -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` «Превью +раскладки через единую логику именования». diff --git a/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/tasks.md b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/tasks.md new file mode 100644 index 0000000..ace267c --- /dev/null +++ b/openspec/changes/archive/2026-07-03-refactor-capability-boundaries/tasks.md @@ -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` по влитым спекам diff --git a/openspec/specs/download-tracking/spec.md b/openspec/specs/download-tracking/spec.md new file mode 100644 index 0000000..6810246 --- /dev/null +++ b/openspec/specs/download-tracking/spec.md @@ -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** переходы применяются последовательно под блокировкой, без гонки + diff --git a/openspec/specs/file-layout/spec.md b/openspec/specs/file-layout/spec.md new file mode 100644 index 0000000..5c6be98 --- /dev/null +++ b/openspec/specs/file-layout/spec.md @@ -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 именоваться `Имя.[.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`, в лог пишется предупреждение + diff --git a/openspec/specs/identity/spec.md b/openspec/specs/identity/spec.md index eea2795..8bab59c 100644 --- a/openspec/specs/identity/spec.md +++ b/openspec/specs/identity/spec.md @@ -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` + diff --git a/openspec/specs/ingest/spec.md b/openspec/specs/ingest/spec.md index d169966..e8ae2e2 100644 --- a/openspec/specs/ingest/spec.md +++ b/openspec/specs/ingest/spec.md @@ -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 + diff --git a/openspec/specs/metadata-match/spec.md b/openspec/specs/metadata-match/spec.md new file mode 100644 index 0000000..824209b --- /dev/null +++ b/openspec/specs/metadata-match/spec.md @@ -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 доступен без повторной + генерации + diff --git a/openspec/specs/notifications/spec.md b/openspec/specs/notifications/spec.md new file mode 100644 index 0000000..6e28ce4 --- /dev/null +++ b/openspec/specs/notifications/spec.md @@ -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** автор загрузки получает уведомление о рассинхроне + diff --git a/openspec/specs/recognition/spec.md b/openspec/specs/recognition/spec.md index c550df9..7e158f8 100644 --- a/openspec/specs/recognition/spec.md +++ b/openspec/specs/recognition/spec.md @@ -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` и в раскладку не попадает diff --git a/openspec/specs/review/spec.md b/openspec/specs/review/spec.md new file mode 100644 index 0000000..c5f1442 --- /dev/null +++ b/openspec/specs/review/spec.md @@ -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 на страницу ревью той же загрузки + diff --git a/openspec/specs/state-reconciliation/spec.md b/openspec/specs/state-reconciliation/spec.md index 5ef6eb9..edfc975 100644 --- a/openspec/specs/state-reconciliation/spec.md +++ b/openspec/specs/state-reconciliation/spec.md @@ -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** система отправляет автору загрузки уведомление о потере источника diff --git a/openspec/specs/web-ui/spec.md b/openspec/specs/web-ui/spec.md index 321c146..e8562ba 100644 --- a/openspec/specs/web-ui/spec.md +++ b/openspec/specs/web-ui/spec.md @@ -268,97 +268,3 @@ jellybit (`created_at`). Порядок MUST быть согласован ме - **WHEN** пользователь раскрывает спойлер переданного контекста - **THEN** контекст показывается нативным `
`, без скриптов -### 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** в предпросмотре присутствует место под режиссёра, показанное - пустым (или прочерком), не ломая вёрстку -