Рефакторинг границ capabilities: цепочка загрузка→матч→ревью→раскладка (openspec)

Привёл набор capabilities в OpenSpec к цепочке обработки, чтобы имя capability
отвечало одному поведению. Чисто по спекам, код и поведение системы не меняются.

Change refactor-capability-boundaries (архивирован):
- recognition разделён на recognition (разбор LLM) + metadata-match (сверка с базами)
- review выделен из web-ui + мигрирован из docs/specs/review-ux.md
- новые capability из docs/specs: file-layout, download-tracking, notifications
- identity очищен до инфра-id; приём (инфохэши, дедуп, ядро приёма) — в ingest
- уведомление о рассинхроне перенесено из state-reconciliation в notifications
- дубль владения путём и безопасного undo оставлен в state-reconciliation

Итог: 11 capabilities, openspec validate --strict проходит (+37/−11 требований).
Источник истины по мигрированным темам переехал в openspec/specs (шапки в docs).
Снят пункт беклога «Пересмотр набора capabilities».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-03 21:17:51 +03:00
co-authored by Claude Opus 4.8
parent b3d7c08f4a
commit 512567c8ba
29 changed files with 1866 additions and 315 deletions
+93
View File
@@ -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** переходы применяются последовательно под блокировкой, без гонки
+92
View File
@@ -0,0 +1,92 @@
# file-layout Specification
## Purpose
Раскладка распознанных файлов хардлинками под библиотеку Jellyfin: целевые
имена фильмов и сериалов (папка/файл, provider-id, сезоны), сопоставление
источник→цель, санитизация пути и запрет выхода за библиотеку, never-overwrite
(коллизия → review) и copy-fallback при невозможности хардлинка. Владение
целевым путём (`superseded`) и безопасный `Undo` (`nlink<=1`) —
в `state-reconciliation`.
## Requirements
### Requirement: Целевые имена фильмов
Фильм система SHALL раскладывать в папку и файл вида `Название (Год)`, помещённые
под `paths.movies`. При подтверждённом матче в базе имя папки SHALL нести
provider-id (`[tmdbid-…]`/`[tvdbid-…]`) — он снимает неоднозначность русских
названий для Jellyfin. Внешние субтитры SHALL именоваться `Имя.<lang>[.flag].srt`
(флаги `forced`/`sdh`/`default`/`hi`), с базой имени, совпадающей с именем
видеофайла; пары VobSub — `.idx` + `.sub`.
#### Scenario: Фильм с provider-id
- **GIVEN** распознанный фильм «Дюна Часть вторая» (2024) с матчем TMDB `693134`
- **WHEN** строится целевой путь
- **THEN** папка = `movies/Дюна Часть вторая (2024) [tmdbid-693134]/`
- **AND** видеофайл = `Дюна Часть вторая (2024).mkv`
### Requirement: Целевые имена сериалов
Сериал система SHALL раскладывать под `paths.series` в папку `Название (Год)` с
provider-id на папке сериала, сезонными подпапками `Season xx` и файлами вида
`Название (Год) SxxEyy`.
#### Scenario: Серия сезона
- **GIVEN** распознанный сериал «Фарго» (2024) с матчем TVDB `123456`, серия S01E02
- **WHEN** строится целевой путь
- **THEN** путь = `series/Фарго (2024) [tvdbid-123456]/Season 01/Фарго (2024) S01E02.mkv`
### Requirement: Сопоставление источник → цель хардлинками
Для каждого распознанного **файла** (не каталога) система SHALL создавать
**хардлинк** в `paths.movies`/`paths.series`; исходный путь берётся из
qBittorrent (`save_path` + относительное имя файла из `/torrents/files`, уже
включающее корневую папку многофайловой раздачи). Целевые каталоги SHALL
создаваться `mkdir` (0755, `1000:1000`). Исходный файл система НЕ SHALL трогать —
раздача продолжается, inode общий, диск не дублируется.
#### Scenario: Хардлинк не дублирует данные
- **GIVEN** видеофайл раздачи под `paths.downloads`
- **WHEN** файл раскладывается
- **THEN** в библиотеке создаётся хардлинк на тот же inode
- **AND** исходный файл остаётся на месте
### Requirement: Санитизация целевого пути и запрет выхода за библиотеку
Целевое имя система SHALL санитизировать (без разделителей пути, `..`,
управляющих символов), а финальный путь SHALL проверять на строгое нахождение под
`paths.movies`/`paths.series`. Путь, выходящий за пределы библиотеки, система НЕ
SHALL создавать. Безопасность SHALL держаться на валидации пути, а не на доверии к
выходу LLM.
#### Scenario: Traversal отклоняется
- **GIVEN** распознанное имя, содержащее `../`
- **WHEN** строится и проверяется целевой путь
- **THEN** путь отклоняется как выходящий за пределы библиотеки, хардлинк не создаётся
### Requirement: Существующую цель не перезаписываем
Существующий целевой файл система НЕ SHALL перезаписывать. Если по целевому пути
уже лежит тот же inode — операция идемпотентна (готово); если другой файл —
это коллизия, и задача SHALL уходить в `review`.
#### Scenario: Коллизия уходит в review
- **GIVEN** по целевому пути уже лежит другой файл
- **WHEN** выполняется раскладка
- **THEN** файл не перезаписывается, задача переходит в `review` с причиной коллизии
### Requirement: Copy-fallback при невозможности хардлинка
Система SHALL при невозможности хардлинка (разные ФС или ФС без поддержки жёстких
ссылок) НЕ падать, а копировать файл с предупреждением в лог, помечая ссылку
статусом `copied`.
#### Scenario: Разные ФС — копирование
- **GIVEN** целевой и исходный каталоги на разных ФС
- **WHEN** выполняется раскладка файла
- **THEN** файл копируется, ссылка получает статус `copied`, в лог пишется предупреждение
+5 -81
View File
@@ -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`
+108 -5
View File
@@ -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
+130
View File
@@ -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 доступен без повторной
генерации
+56
View File
@@ -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** автор загрузки получает уведомление о рассинхроне
+77 -94
View File
@@ -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` и в раскладку не попадает
+173
View File
@@ -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 на страницу ревью той же загрузки
+3 -14
View File
@@ -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** система отправляет автору загрузки уведомление о потере источника
-94
View File
@@ -268,97 +268,3 @@ jellybit (`created_at`). Порядок MUST быть согласован ме
- **WHEN** пользователь раскрывает спойлер переданного контекста
- **THEN** контекст показывается нативным `<details>`, без скриптов
### Requirement: Единый список источников совпадения на ревью
Экран ревью (`/review/{id}`) SHALL показывать совпавшие источники **единым
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
Ровно один источник в списке SHALL быть отмечен активным (эффективный
матч). Экран SHALL позволять как операции над этим списком: выбрать
кандидата базы, переключиться на другого кандидата и снять матч с базой
обратно на нейронку («без базы»). Смена активного источника SHALL
выполняться через раундтрип на сервер (форма/htmx), без клиентского
пересчёта доменного состояния. Список источников SHALL показываться только
при наличии плана распознавания.
#### Scenario: Нейронка — строка в общем списке
- **GIVEN** загрузка в `review` с распознаванием нейронкой и одним или
несколькими кандидатами метабаз
- **WHEN** пользователь открывает `GET /review/{id}`
- **THEN** источники показаны единым списком, где строка «распознано
нейронкой» стоит наравне с кандидатами баз
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
#### Scenario: Переключение между кандидатами
- **GIVEN** на экране ревью выбран один кандидат метабазы
- **WHEN** пользователь выбирает другого кандидата из списка
- **THEN** активным становится выбранный кандидат, прочие — неактивны
#### Scenario: Снятие матча в пользу нейронки
- **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo»
- **WHEN** пользователь выбирает строку «распознано нейронкой»
- **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег
папки провайдера не проставляется
- **AND** поля источника — из распознавания нейронкой, без унаследованных
от прежнего кандидата название/год
### Requirement: Ручное добавление источника по id или URL
Когда автопоиск по базам промахнулся, экран ревью SHALL позволять добавить
источник вручную — по идентификатору записи метабазы или, где применимо, по
её URL. Ввод SHALL разбираться и валидироваться в пару
`(provider, provider_id)` на входной границе (`internal/httpapi`); допустимые
провайдеры — `tmdb`, `tvdb`, `imdb`. Добавленный источник SHALL появляться в
списке как выбираемая строка; при совпадении `provider:id` с уже присутствующим
источником новая строка NOT создаётся, а выбирается существующая.
Некорректный ввод SHALL отклоняться с сообщением, не меняя текущий активный
источник.
#### Scenario: Добавление кандидата по URL TMDB
- **GIVEN** загрузка в `review`, где нужной записи нет среди автокандидатов
- **WHEN** пользователь вводит URL записи TMDB и подтверждает добавление
- **THEN** из URL извлекаются провайдер и id, источник добавляется в список
выбираемой строкой
#### Scenario: Дубль id выбирает существующую строку
- **GIVEN** в списке уже есть кандидат с данным `provider:id`
- **WHEN** пользователь добавляет вручную тот же `provider:id`
- **THEN** новая строка не создаётся, активным становится существующий
кандидат
#### Scenario: Некорректный ввод отклонён
- **WHEN** пользователь вводит нераспознаваемый id/URL
- **THEN** экран показывает сообщение об ошибке и не меняет текущий активный
источник
### Requirement: Предпросмотр полей источника до фиксации выбора
Экран ревью SHALL показывать для рассматриваемого источника (нейронка,
кандидат базы или добавленный вручную) **поля** результата — тип, название,
год, с зарезервированным местом под режиссёра. Показ полей источника
MUST NOT менять сохранённый матч загрузки и MUST NOT создавать хардлинки:
сохранённый матч меняется только явным выбором источника, а раскладка —
только действием «Применить». Совпадение целевых путей предпросмотра с
результатом применения регулируется требованием «Превью раскладки через
единую логику именования».
#### Scenario: Предпросмотр полей без фиксации выбора
- **GIVEN** список источников на экране ревью
- **WHEN** пользователь рассматривает источник, ещё не выбрав его активным
- **THEN** показаны поля результата (тип, название, год) для этого источника
- **AND** сохранённый матч загрузки не меняется, хардлинки не создаются
#### Scenario: Зарезервированное место под режиссёра
- **GIVEN** режиссёр из метабазы пока не загружается
- **WHEN** отображается предпросмотр полей источника
- **THEN** в предпросмотре присутствует место под режиссёра, показанное
пустым (или прочерком), не ломая вёрстку