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