Единый источник полей отображаемого имени и один рендер полного ярлыка на всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный «Название (режиссёр, год). Сезон 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>
632 lines
48 KiB
Markdown
632 lines
48 KiB
Markdown
# 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` пуст
|
||
|