# 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), название, год, режиссёра и (для сериала) номер сезона. Год и режиссёр — опциональные поля. Если и контекст, и подсказка из полей источника (`dn` magnet / имя `.torrent`) пусты, система SHALL считать имя не выведенным и SHALL NOT вызывать LLM (выводить имя не из чего — вызов на пустом входе способен лишь галлюцинировать). В этом случае загрузка добавляется без `rename`, а `download.display_name` остаётся пустым. Название 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** система не прерывает приём и переходит к алгоритмическому фолбеку #### Scenario: Пустой вход — LLM не вызывается - **GIVEN** пойманная загрузка без пользовательского контекста и без подсказки из полей источника (голый magnet без `dn`) - **WHEN** система выводит отображаемое имя на шаге добавления - **THEN** LLM не вызывается, имя считается не выведенным - **AND** загрузка добавляется без `rename`, а `download.display_name` пуст (заголовок в веб-UI берётся из фолбека) ### Requirement: Алгоритмический фолбек вывода имени без сети При неудаче LLM система SHALL выводить имя алгоритмически, без сетевых запросов: брать первую содержательную строку контекста (без ссылок, команд бота и UI-мусора), отсекать технические характеристики, очищать и обрезать по длине. Если и фолбек не дал непустого имени, система SHALL добавить загрузку без параметра `rename`. #### Scenario: Фолбек извлекает имя из контекста - **WHEN** LLM недоступен или исчерпал попытки, а контекст содержательный - **THEN** система берёт первую содержательную строку контекста, отсекает технические характеристики и использует результат как отображаемое имя - **AND** при этом не делается ни одного сетевого запроса #### Scenario: Фолбек тоже пуст - **WHEN** ни LLM, ни алгоритмический фолбек не дали непустого имени - **THEN** система добавляет загрузку без параметра `rename` ### Requirement: Обновление отображаемого имени по распознаванию Система SHALL уметь обновлять отображаемое имя загрузки после того, как распознавание дало каноническое название, — переливая уже вычисленное имя (без нового вызова LLM) в `download.display_name` и в имя раздачи qBittorrent. Источником имени SHALL быть **эффективное** распознанное название: пины `title`/`year` (если матч подтверждён), иначе `recognition.title`/`year` — то же название, что использует раскладка. Формат SHALL быть коротким детерминированным ярлыком `Title (Year)` (год опционален), с той же очисткой от управляющих символов и обрезкой по длине, что и вывод имени на шаге добавления. Пустой источник (нет распознавания или пустое название) SHALL приводить к отсутствию изменений (no-op). Переименование раздачи в qBittorrent SHALL адресоваться по infohash своей раздачи и SHALL быть best-effort: сбой (раздача удалена, qBittorrent недоступен) SHALL NOT проваливать обновление — `download.display_name` обновляется в любом случае, ошибка внешнего вызова логируется. Как и на шаге добавления, отображаемое имя SHALL влиять только на отображение и SHALL NOT влиять на пути файлов, распознавание или раскладку. Обновление имени SHALL иметь две точки входа: **авто** — при подтверждённом матче (см. capability `review`); **ручную** — по явному действию пользователя. Ручное действие SHALL перезаписывать текущее имя всегда; авто SHALL перезаписывать, когда выведенное имя непусто. #### Scenario: Перелив имени в загрузку и раздачу - **GIVEN** загрузка с распознанным непустым каноническим названием - **WHEN** запускается обновление отображаемого имени - **THEN** `download.display_name` устанавливается в `Title (Year)` - **AND** раздача в qBittorrent переименовывается в то же имя (по infohash своей раздачи) #### Scenario: qBittorrent недоступен — имя у загрузки всё равно обновлено - **GIVEN** обновление отображаемого имени с выведенным непустым именем - **WHEN** переименование раздачи в qBittorrent завершается ошибкой (недоступен или раздача удалена) - **THEN** `download.display_name` всё равно обновлён - **AND** ошибка внешнего вызова qBittorrent логируется, операция не проваливается #### Scenario: Нет распознавания — обновление ничего не делает - **GIVEN** загрузка без распознанного названия (пустой источник имени) - **WHEN** запускается обновление отображаемого имени - **THEN** ни `download.display_name`, ни имя раздачи не меняются (no-op) ### 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 #### Scenario: Дедуп-дозапись не крадёт чужой хеш - **GIVEN** активная загрузка A владеет хешем `v1`, активная загрузка B владеет хешем `v2` того же гибридного торрента - **WHEN** принимается источник с обоими хешами `{v1, v2}` и дедупится на B - **THEN** B получает только незанятые хеши, а `v1` (в собственности A) B не дописывается - **AND** инвариант «не более одной активной загрузки на infohash» сохраняется (по `v1` активна только A) ### 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** приём проходит штатно (пустой контекст допустим) ### Requirement: Приём источника из .torrent-файла Приём SHALL принимать источник в виде **байтов `.torrent`-файла** (наряду с magnet-ссылкой) — тем же быстрым use-case, общим для транспортов. Получив непустые байты торрента, система SHALL разобрать их локально (без сети), извлечь инфохэш(и) и завести загрузку с `source_type = torrent`, после чего сразу вернуть ответ транспорту (синхронный путь к qBittorrent не обращается — добавление делает воркер, см. `download-tracking`). Инфохэши система SHALL извлекать такими, какими их сообщает qBittorrent, чтобы сопоставление раздач и дедупликация работали: v1-хеш (для v1/гибридного файла) SHALL вычисляться как SHA1 **исходных** байтов info-словаря (без переэнкода); v2-хеш (для v2/гибридного файла, BEP52) SHALL извлекаться как 64-hex `infohash_v2`. Для чистого v2-only файла система SHALL записывать v2-хеш (v1 у него нет). Извлечение всех известных хешей и дозапись недостающих подчиняются требованию «Множество инфохэшей загрузки». Дедупликацию по активной задаче, атомарное заведение (`download` в состоянии `catched` + записи `download_infohash`) и инвариант «не более одной активной загрузки на infohash» torrent-приём SHALL проходить тем же атомарным путём, что и magnet (см. «Приём источника и заведение загрузки», «Дедупликация приёма по любому из хешей», «Атомарность возврата загрузки в активное состояние»). Байты `.torrent` система SHALL сохранять персистентно, привязанными к загрузке, чтобы воркер мог добавить источник в qBittorrent именно файлом (не по magnet): раздачи закрытых трекеров и торренты без DHT по magnet-хешу метаданные не получат. Сохранение байтов SHALL выполняться в той же write-транзакции, что и заведение загрузки; при дедупликации (новая загрузка не создана) байты в общем случае сохраняться SHALL NOT. **Исключение — апгрейд пойманной magnet-задачи до torrent.** Если входящий источник — байты `.torrent`, а дедуп попал на активную загрузку с `source_type = magnet`, ещё НЕ отданную в qBittorrent (состояние `catched`), система SHALL в одной write-транзакции сохранить байты `.torrent`, привязав их к этой загрузке, и сменить её `source_type` на `torrent`. Тем самым воркер добавит раздачу файлом, а не magnet-хешем (иначе на закрытом трекере без DHT метаданные не докачаются, а magnet застрянет в metaDL → failed). Апгрейд SHALL применяться ТОЛЬКО пока загрузка в `catched` (воркер источник ещё не добавил); для уже добавленной (`downloading` и далее) загрузки смена `source_type` при дедупе выполняться SHALL NOT — её судьба решается путями retry/сверки, а не приёмом. Апгрейд SHALL быть best-effort: его неуспех приём не прерывает. Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий) и **дополнять** им контекст транспорта — тем же правилом слияния, что и синтез из полей magnet (пользовательский текст первым; при пустом тексте — только синтез). Обогащённый контекст система SHALL сохранять в `download.Context`. Синтез SHALL выполняться без сетевых запросов. `source_ref` у torrent-загрузки SHALL быть человекочитаемым референсом (имя раздачи или файла), а НЕ адресом добавления: добавление в qBittorrent идёт байтами, и трактовать `source_ref` как magnet/URL для добавления система SHALL NOT. #### Scenario: Быстрый приём .torrent-файла - **GIVEN** валидные байты `.torrent`-файла и (опц.) текст контекста - **WHEN** вызывается приём - **THEN** из файла извлекаются инфохэши и создаётся `download` в состоянии `catched` (`source_type = torrent`) с записями `download_infohash` - **AND** байты файла сохраняются привязанными к загрузке - **AND** ответ транспорту отдан без обращения к qBittorrent #### Scenario: Инфохэш из исходных байтов info - **WHEN** система разбирает v1/гибридный `.torrent`-файл - **THEN** инфохэш v1 вычисляется как SHA1 исходных байтов info-словаря - **AND** совпадает с хешем, по которому qBittorrent позже сопоставит раздачу #### Scenario: v2-only файл записывается под v2-хешем - **WHEN** система разбирает `.torrent` только с метаданными v2 (без v1) - **THEN** у загрузки записывается v2-хеш (64-hex), совпадающий с `infohash_v2` qBittorrent - **AND** сопоставление раздачи работает по нему #### Scenario: Дубль .torrent по активной torrent-задаче - **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash и `source_type = torrent` - **WHEN** принимается `.torrent` с тем же инфохэшем - **THEN** новая загрузка не создаётся, возвращается существующая - **AND** байты торрента повторно не сохраняются (дубль) #### Scenario: Апгрейд catched-magnet до torrent - **GIVEN** активная загрузка в `catched` с `source_type = magnet` и хешем `h` (magnet-задача ещё не отдана в qBittorrent) - **WHEN** принимается `.torrent` с тем же инфохэшем `h` - **THEN** новая загрузка не создаётся, возвращается существующая - **AND** байты `.torrent` сохраняются привязанными к ней, а её `source_type` становится `torrent` — в одной транзакции - **AND** воркер добавит раздачу файлом (не по magnet) #### Scenario: Magnet-задача уже добавлена — апгрейда нет - **GIVEN** активная загрузка с `source_type = magnet` уже в `downloading` (отдана в qBittorrent) - **WHEN** принимается `.torrent` с тем же инфохэшем - **THEN** возвращается существующая загрузка, её `source_type` остаётся `magnet`, байты `.torrent` не сохраняются #### Scenario: Контекст из полей файла - **WHEN** принят `.torrent` с именем раздачи, деревом файлов и трекерами - **THEN** в `download.Context` добавляется синтез (имя, размер, сигнал по файлам, домен трекера), дополняющий текст транспорта - **AND** синтез выполнен без сетевых запросов #### Scenario: Слишком большой .torrent отклоняется - **WHEN** принимаемый `.torrent`-файл превышает ограничение размера - **THEN** приём отклоняется с ошибкой, загрузка не создаётся