Синтезируем контекст из полей самой magnet-ссылки (dn, xl, tr/xs, kt) без сети и дополняем им текст от транспорта: пользовательский текст первым, при пустом — синтез единственный. Обогащённый контекст идёт только в download.Context (его читают recognition и веб-UI); вход namer и отображаемое имя не меняются — строки-факты (Размер:/Трекер:) в display_name не текут. - magnet.Info: поля ExactLength/Sources/Keywords + Info.Context() (синтез, отсев dn-заглушек *-topic-<id>, домен трекера, человекочитаемый размер) - Parse устойчив к ссылке в процент-кодировке (разовый QueryUnescape) - ingest: mergeContext → download.Context, namer на сыром req.Context - Влита дельта capability ingest в openspec/specs, change заархивирован Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
307 lines
21 KiB
Markdown
307 lines
21 KiB
Markdown
# ingest Specification
|
||
|
||
## Purpose
|
||
|
||
Приём загрузки — единый use-case для всех транспортов (HTTP, Telegram, CLI):
|
||
парс источника (Ф1 — magnet), извлечение инфохэшей (`download_infohash`),
|
||
дедупликация по активной задаче, атомарное заведение `download` и отдача
|
||
источника в qBittorrent, а также вывод человекочитаемого отображаемого имени из
|
||
контекста (через LLM или алгоритмический фолбек). Держит инвариант «не более
|
||
одной активной загрузки на infohash» (атомарный возврат в активное состояние).
|
||
## Requirements
|
||
### Requirement: Отображаемое имя торрента из контекста
|
||
|
||
При добавлении загрузки в qBittorrent система SHALL выводить из контекста
|
||
загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent
|
||
(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не
|
||
показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL **сохранять
|
||
у загрузки** (`download.display_name`) для последующего показа заголовком в
|
||
веб-UI.
|
||
|
||
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
|
||
год; для сериала — номер сезона, если он определён), а не куском сырого
|
||
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
|
||
SHALL обрезаться по ограничению длины.
|
||
|
||
Вывод имени SHALL выполняться синхронно перед отдачей источника в
|
||
qBittorrent (параметр `rename` действует только в момент добавления).
|
||
|
||
Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как
|
||
заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание
|
||
или раскладку — реальные пути система по-прежнему читает из qBit API.
|
||
|
||
#### Scenario: Имя из контекста передаётся в qBittorrent
|
||
|
||
- **WHEN** загрузку добавляют с непустым контекстом, из которого удалось
|
||
получить имя
|
||
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
|
||
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
|
||
где режиссёр и год опциональны
|
||
|
||
#### Scenario: Имя сохраняется у загрузки
|
||
|
||
- **WHEN** при приёме получено непустое отображаемое имя
|
||
- **THEN** система сохраняет его в `download.display_name` вместе с созданием
|
||
загрузки
|
||
- **AND** веб-UI использует его заголовком карточки и страницы загрузки
|
||
|
||
#### Scenario: Контекст пуст или имя не получено
|
||
|
||
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
|
||
- **THEN** система добавляет загрузку без параметра `rename`
|
||
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
|
||
- **AND** `download.display_name` остаётся пустым, а веб-UI берёт заголовок из
|
||
фолбека (распознанное название или усечённый источник)
|
||
|
||
### Requirement: Вывод имени через LLM со структурированным выводом
|
||
|
||
Система SHALL строить отображаемое имя с помощью LLM (структурированный
|
||
JSON-вывод), извлекая из контекста тип (movie/series), название, год,
|
||
режиссёра и (для сериала) номер сезона. Год и режиссёр — опциональные поля.
|
||
|
||
Название SHALL быть на русском языке для российского контента и на
|
||
английском (оригинальном) — для остального.
|
||
|
||
Система SHALL предпринять ограниченное число попыток получить от LLM валидный
|
||
результат (корректный JSON с непустым названием); бюджет попыток —
|
||
`[llm].max_retries` (по умолчанию 3). Транспортные ретраи провайдера LLM
|
||
(сетевые сбои, 429, 5xx) в этот счёт не входят.
|
||
|
||
Недоступность или ошибка LLM SHALL NOT прерывать приём загрузки: система
|
||
переходит к алгоритмическому фолбеку.
|
||
|
||
#### Scenario: LLM возвращает структурированное имя
|
||
|
||
- **WHEN** LLM по контексту возвращает валидный JSON с непустым названием
|
||
- **THEN** система формирует отображаемое имя из его полей (название, год,
|
||
для сериала — сезон)
|
||
|
||
#### Scenario: Российский контент — название на русском
|
||
|
||
- **WHEN** контент распознан как российский
|
||
- **THEN** в отображаемом имени используется русское название
|
||
|
||
#### Scenario: Исчерпан бюджет попыток LLM
|
||
|
||
- **WHEN** LLM за отведённые попытки (`[llm].max_retries`) не вернул валидный
|
||
результат либо недоступен
|
||
- **THEN** система не прерывает приём и переходит к алгоритмическому фолбеку
|
||
|
||
### Requirement: Алгоритмический фолбек вывода имени без сети
|
||
|
||
При неудаче LLM система SHALL выводить имя алгоритмически, без сетевых
|
||
запросов: брать первую содержательную строку контекста (без ссылок, команд
|
||
бота и UI-мусора), отсекать технические характеристики, очищать и обрезать
|
||
по длине.
|
||
|
||
Если и фолбек не дал непустого имени, система SHALL добавить загрузку без
|
||
параметра `rename`.
|
||
|
||
#### Scenario: Фолбек извлекает имя из контекста
|
||
|
||
- **WHEN** LLM недоступен или исчерпал попытки, а контекст содержательный
|
||
- **THEN** система берёт первую содержательную строку контекста, отсекает
|
||
технические характеристики и использует результат как отображаемое имя
|
||
- **AND** при этом не делается ни одного сетевого запроса
|
||
|
||
#### Scenario: Фолбек тоже пуст
|
||
|
||
- **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
|
||
|
||
### Requirement: Синтез контекста распознавания из полей magnet
|
||
|
||
При приёме система SHALL извлекать из полей magnet-ссылки дополнительный
|
||
контекст и **дополнять** им контекст, пришедший из транспорта: факты из полей
|
||
SHALL добавляться к пользовательскому тексту (пользовательский текст —
|
||
первым), а при пустом тексте SHALL становиться единственным контекстом.
|
||
Синтез SHALL выполняться только из самой ссылки, без сетевых запросов.
|
||
|
||
В синтез SHALL включаться следующие поля, когда они присутствуют:
|
||
|
||
- `dn` (display name) — как строка названия релиза, **если** это содержательное
|
||
имя, а не заглушка-идентификатор вида `*-topic-<id>` (например
|
||
`rutracker-topic-6514485`); такие заглушки в контекст-название включаться
|
||
SHALL NOT.
|
||
- `xl` (exact length) — как человекочитаемый размер (например «≈ 2.1 GiB»);
|
||
нечисловое/некорректное значение игнорируется.
|
||
- `tr`/`xs` (трекеры / exact source) — как сигнал происхождения по домену
|
||
(хост трекера/источника), помогающий определить язык и тип контента.
|
||
- `kt` (keyword topic) — как ключевые слова.
|
||
|
||
Обогащённый контекст система SHALL сохранять в `download.Context` — его читают
|
||
recognition (LLM-промпт) и веб-UI (страница загрузки). Синтез и слияние SHALL
|
||
выполняться до создания загрузки.
|
||
|
||
Синтезированные строки-факты (размер, домен трекера, ключевые слова) SHALL NOT
|
||
влиять на вывод отображаемого имени (`download.display_name` / параметр
|
||
`rename` qBittorrent): вход вывода имени остаётся прежним (пользовательский
|
||
текст и подсказка `dn`), см. требование «Отображаемое имя торрента из
|
||
контекста».
|
||
|
||
Приём **только по magnet** (пустой текст контекста) SHALL быть штатным
|
||
сценарием во всех транспортах (HTTP, Telegram, CLI).
|
||
|
||
#### Scenario: dn — содержательное имя релиза
|
||
|
||
- **WHEN** magnet содержит `dn` с релиз-именем (например
|
||
`Dune.Part.Two.2024.2160p.BluRay`)
|
||
- **THEN** это имя добавляется в `download.Context`
|
||
- **AND** становится доступно recognition
|
||
|
||
#### Scenario: dn — заглушка-идентификатор темы
|
||
|
||
- **WHEN** `dn` имеет вид `*-topic-<id>` (например `rutracker-topic-6514485`)
|
||
- **THEN** система не включает его как строку-название в контекст
|
||
- **AND** остальные поля magnet (размер, домен трекера) всё равно синтезируются
|
||
|
||
#### Scenario: Размер из xl
|
||
|
||
- **WHEN** magnet содержит корректный числовой `xl`
|
||
- **THEN** в `download.Context` добавляется человекочитаемый размер загрузки
|
||
|
||
#### Scenario: Происхождение по домену трекера
|
||
|
||
- **WHEN** magnet содержит `tr` и/или `xs` с распознаваемым хостом
|
||
- **THEN** в `download.Context` добавляется сигнал происхождения (домен),
|
||
пригодный как подсказка языка/типа контента
|
||
|
||
#### Scenario: Дополнение непустого пользовательского контекста
|
||
|
||
- **WHEN** транспорт передал непустой текст контекста, а magnet несёт поля
|
||
- **THEN** `download.Context` содержит и текст пользователя, и факты из полей
|
||
magnet (текст пользователя не теряется)
|
||
- **AND** текст пользователя идёт первым
|
||
|
||
#### Scenario: Приём только по magnet
|
||
|
||
- **WHEN** magnet принят с пустым текстом контекста
|
||
- **THEN** приём проходит штатно
|
||
- **AND** `download.Context` синтезируется из полей magnet и сохраняется
|
||
|
||
#### Scenario: Строки-факты не становятся отображаемым именем
|
||
|
||
- **GIVEN** голый magnet с заглушкой `dn=rutracker-topic-<id>` и трекером, без
|
||
пользовательского текста
|
||
- **WHEN** выполняется приём
|
||
- **THEN** `download.display_name` не выводится из строк-фактов (не равен
|
||
`Трекер: …`/`Размер: …`)
|
||
- **AND** отображаемое имя определяется прежним путём (подсказка `dn` или его
|
||
отсутствие → без `rename`)
|
||
|
||
#### Scenario: Синтез без сети
|
||
|
||
- **WHEN** выполняется извлечение контекста из полей magnet
|
||
- **THEN** не делается ни одного сетевого запроса (только разбор строки
|
||
ссылки)
|
||
|
||
#### Scenario: Полей для контекста нет
|
||
|
||
- **WHEN** текст пользователя пуст и ни одно пригодное поле magnet не даёт
|
||
содержательного контекста (нет `dn`-имени, `xl`, распознаваемого домена,
|
||
`kt`)
|
||
- **THEN** `download.Context` остаётся пустым
|
||
- **AND** приём проходит штатно (пустой контекст допустим)
|
||
|