# ingest Specification ## Purpose Приём загрузки — единый use-case для всех транспортов (HTTP, Telegram, CLI): парс источника (Ф1 — magnet), извлечение инфохэшей (`download_infohash`), дедупликация по активной задаче, атомарное заведение `download` и отдача источника в qBittorrent, а также вывод человекочитаемого отображаемого имени из контекста (через LLM или алгоритмический фолбек). Держит инвариант «не более одной активной загрузки на infohash» (атомарный возврат в активное состояние). ## Requirements ### Requirement: Отображаемое имя торрента из контекста На шаге добавления пойманной загрузки в qBittorrent (worker) система SHALL выводить из контекста загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent (параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL **сохранять у загрузки** (`download.display_name`) для последующего показа заголовком в веб-UI. Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и год; для сериала — номер сезона, если он определён), а не куском сырого контекста. Имя SHALL очищаться от управляющих символов и переводов строк и SHALL обрезаться по ограничению длины. Вывод имени SHALL выполняться на шаге добавления, непосредственно перед вызовом `add` (параметр `rename` действует только в момент добавления), а НЕ в синхронном пути ответа приёма. В состоянии `catched` (до добавления) `download.display_name` ещё пуст — веб-UI берёт заголовок из фолбека. Отображаемое имя 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` в состоянии **`catched`** + записи `download_infohash`), после чего **сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL выполняться атомарно (см. «Атомарность возврата загрузки в активное состояние»). Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить отображаемое имя (потенциально медленный LLM): и добавление источника в qBittorrent, и вывод имени выполняются отдельным асинхронным шагом машины состояний (worker) — см. `download-tracking` «Добавление пойманной загрузки в qBittorrent». `catched` — нетерминальное активное состояние: оно участвует в инварианте «не более одной активной загрузки на infohash» наравне с прочими активными. #### Scenario: Быстрый приём magnet - **GIVEN** валидная magnet-ссылка и контекст - **WHEN** вызывается приём - **THEN** создаётся `download` в состоянии `catched` с записями `download_infohash` - **AND** ответ транспорту отдан без обращения к qBittorrent и без вывода имени #### Scenario: Дубль по активной задаче на быстром пути - **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash - **WHEN** вызывается приём - **THEN** новая загрузка не создаётся, возвращается существующая ### 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-` (например `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-` (например `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-` и трекером, без пользовательского текста - **WHEN** выполняется приём - **THEN** `download.display_name` не выводится из строк-фактов (не равен `Трекер: …`/`Размер: …`) - **AND** отображаемое имя определяется прежним путём (подсказка `dn` или его отсутствие → без `rename`) #### Scenario: Синтез без сети - **WHEN** выполняется извлечение контекста из полей magnet - **THEN** не делается ни одного сетевого запроса (только разбор строки ссылки) #### Scenario: Полей для контекста нет - **WHEN** текст пользователя пуст и ни одно пригодное поле magnet не даёт содержательного контекста (нет `dn`-имени, `xl`, распознаваемого домена, `kt`) - **THEN** `download.Context` остаётся пустым - **AND** приём проходит штатно (пустой контекст допустим)