Files
jellybit/openspec/changes/add-qbt-display-name/design.md
T

11 KiB

Context

ingest.Ingest() принимает источник (magnet) и текстовый контекст (вычищенный заголовок релиза от торрент-бота), дедуплицирует по infohash, заводит задачу и отдаёт источник в qBittorrent через qbt.Add(). Сейчас qbt.AddRequest несёт URLs/Category/SavePath/Paused, но не имя — в списке qBit задача показывается своим dn из magnet, часто мусорным (rutracker-topic-…).

В проекте уже есть всё нужное: llm.Provider (один вызов модели, JSONMode, транспортные ретраи внутри; бюджет переразбора схемы — на стороне вызывающего, как в recognize), пред-парс имени через go-ptn, контекст с заголовком релиза. API qBittorrent /torrents/add принимает поле rename, задающее отображаемое имя торрента.

Ключевое ограничение: rename действует только в момент добавления — значит вывод имени должен случиться синхронно, до qbt.Add().

Goals / Non-Goals

Goals:

  • Из контекста загрузки получать короткое читаемое имя и класть его в qBit.
  • Имя строит LLM (структурированный вывод): название, год, режиссёр, тип, сезон; язык названия — русский для российского контента, иначе английский.
  • До трёх попыток получить валидное имя от LLM; иначе алгоритмический фолбек без сети.
  • Вывод имени — в ядре (ingest), общий для всех транспортов; деградирует штатно и никогда не валит приём загрузки.

Non-Goals:

  • Переименование уже добавленных задач; влияние на пути на диске и на recognize/layout (имя — только ярлык в qBit).
  • Сетевая сверка имени с метабазами (TMDB/TVDB) — это recognize, не здесь.
  • Источники кроме magnet.

Decisions

Решение 1: вывод имени — синхронно в ingest, перед qbt.Add()

rename применяется лишь при добавлении, поэтому имя считается до отдачи источника в qBit. Вывод живёт в ядре ingest (принцип «единое ядро, тонкие транспорты»): транспорты по-прежнему передают только Source + Context.

  • Альтернатива (отклонена): добавить торрент paused, переименовать отдельным вызовом API, снять с паузы. Сложнее, лишние запросы, гонка с поллингом — выгоды для ярлыка не оправдывают.
  • Плата: приём становится зависим от LLM по латентности. Гасится ограниченным таймаутом и быстрым фолбеком (см. Решение 4 и Риски).

Решение 2: имя строит LLM со структурированным выводом

Отдельный узкий промпт (не трогаем схему recognize): на вход — контекст (и, как подсказка, имя/dn из magnet), на выход — строгий JSON:

{
  "type": "movie" | "series",
  "title": "название на нужном языке",
  "original_title": "оригинальное название или пустая строка",
  "year": число или 0,
  "director": "режиссёр или пустая строка",
  "season": число или null,
  "is_russian": true | false
}

title модель отдаёт уже на нужном языке: для российского контента (is_russian=true) — русское название, иначе — английское/оригинальное. director и year — опциональные поля; если модель их извлекла, они попадают в ярлык (Решение 3).

  • Почему LLM, а не только go-ptn/регэкспы: контекст — вольный человеческий текст с двойными названиями (Рус / Eng), годом внутри скобок и тех. характеристиками; алгоритмически чисто вытащить «красивое» имя ненадёжно. go-ptn остаётся фолбеком (Решение 4).
  • Недоверенный вывод: результат — только ярлык в qBit, на пути и инварианты не влияет; жёсткая валидация пути здесь не нужна, но имя очищается от управляющих символов и переводов строк и обрезается по длине.

Решение 3: формат отображаемого имени

Рендер имени — чистая функция от структуры. Режиссёр и год — опциональные части скобки; внутри неё порядок «режиссёр, год»:

  • оба: Title (Director, Year)Дюна: Часть вторая (Дени Вильнёв, 2024).
  • только год: Title (Year); только режиссёр: Title (Director); без обоих: Title (скобка опускается).
  • series: к любому из вариантов добавляется . Сезон N, если сезон есть.

Имя держим коротким и без тех. характеристик; original_title остаётся в структуре, но в строку не добавляется. Длина ограничивается (напр. 200 символов).

Решение 4: три попытки LLM, затем алгоритмический фолбек

«Попытка» — получить от модели валидный JSON с непустым title. Бюджет — существующий [llm].max_retries (по умолчанию 3; тот же, что у переразбора схемы в recognize); транспортные ретраи (сеть/429/5xx) остаются внутри llm.Provider и в этот счёт не входят. Исчерпали попытки или LLM недоступна → алгоритмический фолбек: первая содержательная строка контекста (та же логика, что в tgbot.cleanContext: без ссылок, команд и UI-мусора), срез до тех. характеристик (до [/( с годом), очистка и обрезка по длине. Фолбек — без сетевых запросов.

Если и фолбек пуст (контекста нет/он бесполезен) → Rename не задаём, qBittorrent оставляет своё имя. Поведение «без контекста» не меняется.

  • Бюджет попыток: переиспользуем существующий [llm].max_retries (default 3). Семантика чуть иная, чем у переразбора схемы, но для проекта такого размера отдельный параметр избыточен — разделим при необходимости.

Решение 5: интерфейс вывода имени и graceful-деградация

ingest зависит от узкого интерфейса (напр. Namer/функция DeriveName(ctx, Context, magnetHint) string), реализованного поверх llm.Provider + фолбек. Так вывод имени тестируется без сети, а при отсутствии настроенного LLM работает только фолбек. Любая ошибка вывода имени логируется и не прерывает Ingest: пустое имя → добавляем без rename.

Решение 6: qbt.AddRequest.Rename

Добавляем поле Rename string; при непустом значении пишем form-field rename. Пустое — поле не отправляется (поведение не меняется).

Risks / Trade-offs

  • Латентность приёма из-за вызова LLM → вывод имени ограничен общим [llm].timeout; по таймауту/ошибке — фолбек. Приём не должен зависать на медленной модели.
  • Стоимость токенов на каждую загрузку → промпт узкий и короткий; расход уже снимается с провода (llm.Response.Usage). При желании в будущем — кэш/выключатель, вне объёма.
  • rename затрагивает имя корневой папки многофайловой раздачи → downstream безопасен: jellybit всегда читает реальные пути из qBit API (Files, content_path), а не выводит их из имени. Инвариант «источник неприкосновенен» цел — переименование делает сам qBittorrent при добавлении. Перепроверить руками на реальном qBit (Open Questions).
  • Галлюцинация имени LLM → последствия минимальны (всего лишь ярлык); имя очищается и обрезается; на распознавание/раскладку не влияет.

Migration Plan

Чистое добавление, без миграций БД и слома API. Включается само (если LLM настроен — работает LLM-путь, иначе только фолбек). Откат — снять проброс Rename (старые задачи в qBit не затрагиваются).

Open Questions

  • Подтвердить на реальном qBittorrent, что rename меняет отображаемое имя (и поведение для многофайловой раздачи) ожидаемо. Решается верификацией на задаче 6.2; код от ответа не зависит (пути берутся из qBit API).

Решено: бюджет попыток — [llm].max_retries (default 3); таймаут вывода имени — общий [llm].timeout. Отдельные параметры не вводим: провайдер LLM один, паттерн обращения общий; разделим, если появится второй провайдер.