Files
avandClaude Opus 4.8 02d4ecc2aa display_name: слоистое разрешение полей + сохранение режиссёра из контекста
Единый источник полей отображаемого имени и один рендер полного ярлыка на
всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный
«Название (режиссёр, год). Сезон N» но выбрасывал структуру, а перелив по
распознаванию — усечённый «Title (Year)».

- Слоистое разрешение скаляров имени: override → recognition(+match) →
  новый базовый слой «контекст» (download.parsed_context, JSON naming.Fields).
- naming: публичные Fields/Label/Derive, вынесен единый рендер; удалён
  FormatTitleYear. Сводка сезонов вынесена в recognize.SeasonSummary.
- Режиссёр из метабазы (решение A2): TMDB/TVDB credits через опциональный
  metadata.DirectorProvider; авто-матч кладёт в plan.Director, ручной выбор
  кандидата тянет credits и пиннит ovrDirector. Метабаза бьёт контекст.
- refreshDisplayNameLocked строит полный ярлык из эффективных полей;
  инфо-панель ревью показывает загруженного режиссёра.
- Миграция 0011_parsed_context + ER-схема. Всё косметика: на пути/раскладку
  не влияет, приём/вывод имени не валятся (best-effort).

Закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат ярлыка».
OpenSpec: archive/2026-07-11-field-resolution-display-name (ingest,
recognition, metadata-match, review).

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

632 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 быть **эффективные поля имени**, разрешённые по слоям
сверху вниз (берётся первый непустой слой): (1) ручные правки `override`;
(2) распознавание с вложенным подтверждённым матчем (`recognition`, куда матч
метабазы уже вложил каноничные название/год/режиссёра); (3) сохранённая
структура из контекста (`download.parsed_context`). Так каждое поле берётся из
самого доверенного доступного источника, а данные из контекста (например
режиссёр) не теряются, если распознавание/матч их не дали. Название из слоя
распознавания SHALL совпадать с тем, что использует раскладка (эффективный
`title`), чтобы отображаемое имя не расходилось с целевыми путями.
Формат SHALL быть тем же полным детерминированным ярлыком, что и на шаге
добавления: «Название (режиссёр, год)», где режиссёр и год опциональны, а для
сериала добавляется сводка сезонов («. Сезон N» для одного сезона; «. Сезоны …»
для многосезонного пака; отметка спецвыпусков) — согласованная со сводкой сезонов
на экране просмотра. Применяются та же очистка от управляющих символов и обрезка
по ограничению длины. Пустой источник (нет ни распознавания, ни сохранённой
структуры, дающих непустое название) 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` устанавливается в полный ярлык
«Название (режиссёр, год)» (для сериала — со сводкой сезонов)
- **AND** раздача в qBittorrent переименовывается в то же имя (по infohash своей
раздачи)
#### Scenario: Режиссёр из контекста переживает распознавание без матча
- **GIVEN** загрузка, где режиссёр был извлечён из контекста, а распознавание
прошло без подтверждённого матча (режиссёр из метабазы недоступен)
- **WHEN** запускается обновление отображаемого имени
- **THEN** в ярлыке используется режиссёр из сохранённого контекста
- **AND** название/год берутся из распознавания
#### Scenario: Режиссёр из матча бьёт контекстного
- **GIVEN** загрузка, где режиссёр есть и в контексте, и в подтверждённом матче
- **WHEN** формируется ярлык
- **THEN** используется режиссёр из матча (более доверенный слой)
#### 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
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
дедуплицировать по **блокирующей повторный приём** задаче (активной либо
удерживающей источник ради незакрытого намерения — `target_missing`/`orphaned`;
см. «Дедупликация приёма по любому из хешей») и при отсутствии дубля завести
загрузку (`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 считаться загрузки в активном (нетерминальном) состоянии
**либо** удерживающие источник ради незакрытого намерения — `target_missing`
(источник жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись
держит претензию на последнюю копию). Прочие терминальные состояния (`done`,
`cancelled`, `failed`, `reverted`, `deleted`) блокирующими быть SHALL NOT:
повторный приём такого инфохэша — осознанное «хочу заново» и SHALL заводить
новую загрузку.
Когда найденная блокирующая загрузка терминальна (`target_missing`/`orphaned`),
приём SHALL возвращать её как существующую (`Deduplicated`) **спящей**: система
SHALL NOT переводить её в активное состояние и SHALL NOT обращаться к qBittorrent
(перепривязка — отдельное явное действие пользователя, а не побочный эффект
приёма); ответ транспорту SHALL сообщать, что запись существует и требует
перепривязки либо закрытия.
Атомарный инвариант касается **активной** составляющей: проверка отсутствия
другой активной загрузки на любом из хешей и вставка новой загрузки с её хешами
SHALL выполняться в одной write-транзакции, поддерживая «не более одной активной
загрузки на infohash» (тот же общий active-гард, что у прочих путей активации).
Расширение критерия на desync-состояния (`target_missing`/`orphaned`) SHALL быть
устойчивым пред-ридом до создания, коротко замыкающим приём на возврат
существующей записи; desync-состояния в общий active-гард заводиться SHALL NOT
(их терминальность оставляет `state`-инвариант «активности» нетронутым).
Отдельного снимаемого/восстанавливаемого ключа идемпотентности в схеме быть SHALL
NOT — активность выводится только из `state`.
#### Scenario: Повторный приём при активной загрузке
- **GIVEN** активная загрузка с infohash `h`
- **WHEN** принимается magnet с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая
#### Scenario: Повторный приём при записи без цели
- **GIVEN** загрузка с infohash `h` в `target_missing` (источник жив, цель
удалена)
- **WHEN** принимается magnet с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
`Deduplicated`
- **AND** её состояние остаётся `target_missing` (в активное не переводится, к
qBittorrent обращения нет)
- **AND** ответ транспорту указывает, что запись существует и её нужно привязать
заново или закрыть
#### Scenario: Повторный приём при осиротевшей записи
- **GIVEN** загрузка с infohash `h` в `orphaned` (источник пропал)
- **WHEN** принимается magnet/torrent с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
`Deduplicated`
#### Scenario: Повторный приём после завершения
- **GIVEN** загрузка с infohash `h` в терминальном состоянии `done`
- **WHEN** принимается magnet с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
#### Scenario: Повторный приём после закрытия записи
- **GIVEN** загрузка с infohash `h` в `cancelled` (в т.ч. закрытая из
`target_missing`)
- **WHEN** принимается magnet с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
#### Scenario: Повторный приём при прочих терминальных состояниях
- **GIVEN** загрузка с infohash `h` в `failed` или `reverted` (не удерживает
источник ради незакрытого намерения)
- **WHEN** принимается magnet/torrent с тем же `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_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** приём отклоняется с ошибкой, загрузка не создаётся
### Requirement: Сохранение извлечённой из контекста структуры имени
Система SHALL сохранять структуру имени, извлечённую LLM со структурированным
выводом на шаге добавления (тип, название, оригинальное название, год, режиссёр,
сезон), у загрузки (`download.parsed_context`, JSON), чтобы её поля могли
переиспользоваться при последующем выводе имени без повторного вызова LLM.
Алгоритмический фолбек структуры не даёт (его выход — только строка имени), тогда
`parsed_context` остаётся пустым — это штатно (нижний слой отсутствует). Сохранённая структура SHALL
быть **базовым (наименее доверенным) слоем** источника полей имени: её значения
берутся, только если более доверенный слой (распознавание/матч, ручные правки)
соответствующего поля не дал.
Сохранение SHALL быть best-effort и косметическим: неудача записи `parsed_context`
SHALL NOT проваливать добавление загрузки, а сама структура SHALL влиять только на
отображаемое имя и SHALL NOT влиять на пути файлов, распознавание или раскладку.
Пустая/невыведенная структура (нет контекста и подсказки) SHALL приводить к
пустому `parsed_context` (нечего сохранять).
#### Scenario: Извлечённый режиссёр сохраняется у загрузки
- **GIVEN** контекст загрузки, из которого LLM извлёк режиссёра и год
- **WHEN** система выводит имя на шаге добавления
- **THEN** извлечённая структура (в т.ч. режиссёр) сохраняется в
`download.parsed_context`
- **AND** отображаемое имя формируется как и прежде (полный ярлык)
#### Scenario: Сбой сохранения структуры не валит добавление
- **GIVEN** запись `parsed_context` завершается ошибкой
- **WHEN** идёт шаг добавления загрузки
- **THEN** загрузка всё равно добавляется в qBittorrent (с `rename`, если имя
выведено)
- **AND** ошибка логируется, приём/добавление не проваливается
#### Scenario: Пустой вход — пустая структура
- **GIVEN** пойманная загрузка без контекста и без подсказки из полей источника
- **WHEN** выполняется шаг добавления
- **THEN** структура не выводится, `download.parsed_context` пуст