Files
jellybit/openspec/specs/ingest/spec.md
T
avandClaude Opus 4.8 0d263270cb Быстрый приём: сохранение в catched, добавление в qBittorrent — шаг worker'а
Приём (Ingest) стал быстрым: синхронно только парс magnet, синтез контекста из
полей ссылки, атомарный дедуп и запись загрузки в новое состояние `catched` —
ответ клиенту сразу. Медленный вывод имени (LLM) и добавление в qBittorrent
вынесены в асинхронный шаг машины состояний, который двигает worker.

- store: состояние `catched` (нетерминальное, активная группа); атомарный
  переход PromoteCatched (catched → downloading + display_name) с гардом
  state='catched' (ре-валидация после сетевых вызовов вне блокировки)
- ingest: убраны namer/qbt из пути приёма; пишем `catched`, отвечаем сразу
- worker.processCatched: вне w.mu выводит имя и qbt.Add, под w.mu — короткий
  переход; сбой add оставляет catched (ретрай тиком); предохранитель
  catch_timeout → failed(qbit_add)+notify; catched исключён из проверок пропажи
- config: worker.catch_timeout (дефолт 10m)
- веб-UI: бейдж catched, активная группа, самозавершающийся htmx-поллинг
  карточки/страницы до перехода в downloading; Telegram-текст без сырого catched
- OpenSpec: дельты ingest/download-tracking/web-ui влиты в спеки, change
  заархивирован

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:29:28 +03:00

22 KiB
Raw Blame History

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, kindv1|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 приём проходит штатно (пустой контекст допустим)