Итог параллельной волны фиксов (worktree-изоляция, cherry-pick в master): - ingest-dedup-integrity (F1, F6) → спека ingest - retry-stall-basis (MAJOR-1, MAJOR-2) → спека state-reconciliation - linking-transition-robustness (MAJOR-4, MINOR-7) → спеки file-layout и state-reconciliation Дельты влиты в openspec/specs, changes перенесены в openspec/changes/archive/2026-07-08-*. Беклог не трогаю (по решению). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
32 KiB
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
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-<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 приём проходит штатно (пустой контекст допустим)
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_v2qBittorrent - 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 приём отклоняется с ошибкой, загрузка не создаётся