From 4f2fb5998a5ecc8aa3bf8e9a9a573a0a17a04199 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 28 Jun 2026 12:00:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B5=D0=BA=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20=D1=82=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../add-qbt-display-name/.openspec.yaml | 2 + .../changes/add-qbt-display-name/design.md | 159 ++++++++++++++++++ .../changes/add-qbt-display-name/proposal.md | 62 +++++++ .../add-qbt-display-name/specs/ingest/spec.md | 90 ++++++++++ .../changes/add-qbt-display-name/tasks.md | 56 ++++++ 5 files changed, 369 insertions(+) create mode 100644 openspec/changes/add-qbt-display-name/.openspec.yaml create mode 100644 openspec/changes/add-qbt-display-name/design.md create mode 100644 openspec/changes/add-qbt-display-name/proposal.md create mode 100644 openspec/changes/add-qbt-display-name/specs/ingest/spec.md create mode 100644 openspec/changes/add-qbt-display-name/tasks.md diff --git a/openspec/changes/add-qbt-display-name/.openspec.yaml b/openspec/changes/add-qbt-display-name/.openspec.yaml new file mode 100644 index 0000000..c0d3374 --- /dev/null +++ b/openspec/changes/add-qbt-display-name/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-28 diff --git a/openspec/changes/add-qbt-display-name/design.md b/openspec/changes/add-qbt-display-name/design.md new file mode 100644 index 0000000..11dc6fb --- /dev/null +++ b/openspec/changes/add-qbt-display-name/design.md @@ -0,0 +1,159 @@ +## 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 +один, паттерн обращения общий; разделим, если появится второй провайдер. diff --git a/openspec/changes/add-qbt-display-name/proposal.md b/openspec/changes/add-qbt-display-name/proposal.md new file mode 100644 index 0000000..35333c5 --- /dev/null +++ b/openspec/changes/add-qbt-display-name/proposal.md @@ -0,0 +1,62 @@ +## Why + +При добавлении magnet-загрузки jellybit не передаёт в qBittorrent +человекочитаемое имя, поэтому в списке qBit задачи выглядят безлико: +`rutracker-topic-6514485`. Контекст загрузки (заголовок релиза от +торрент-бота) у нас уже есть — из него можно собрать аккуратное имя +(«Дюна: Часть вторая (2024)») и сразу класть его в qBittorrent. Небольшая +доработка с заметной отдачей в повседневной эксплуатации. + +Это также первая capability, переносимая из `docs/specs` в OpenSpec +(пилот формата): change засевает capability `ingest` дельтой `ADDED`. + +## What Changes + +- `ingest` выводит из контекста загрузки **отображаемое имя** и передаёт + его в qBittorrent при добавлении. +- Имя строится LLM: из контекста извлекаются название, год, режиссёр и (для + сериала) номер сезона; название — на русском для российского контента, + иначе на английском. Результат — короткая читаемая строка, а не кусок + сырого контекста. +- LLM даётся до **трёх попыток**; при неудаче — **алгоритмический фолбек** + (первая содержательная строка контекста, очистка и обрезка по длине) без + сетевых запросов. +- Если ни LLM, ни фолбек не дали имени (контекст пуст/бесполезен) — + отображаемое имя не передаётся: qBittorrent оставляет своё (`dn`/имя из + торрента). Поведение при отсутствии контекста не меняется. +- `qbt.AddRequest` получает поле для отображаемого имени, пробрасываемое в + параметр `rename` API `/torrents/add`. + +Не входит в объём (Non-goals): + +- Переименование уже добавленных в qBittorrent задач. +- Изменение логики распознавания (`recognize`) и раскладки — отображаемое + имя нужно лишь для списка qBit и не влияет на пути на диске (jellybit + по-прежнему читает реальные пути из qBit API). +- Источники кроме magnet (`.torrent`/url) — отдельная задача. + +## Capabilities + +### New Capabilities +- `ingest`: приём загрузки (источник + контекст) — дедупликация по + infohash, заведение задачи и передача источника в qBittorrent. В рамках + этого change добавляется требование о выводе и передаче отображаемого + имени. Базовое поведение приёма фиксируется как контекст существующего + кода. + +### Modified Capabilities + + +## Impact + +- **Код:** `internal/ingest` (вывод имени, новая зависимость на LLM-провайдер + и алгоритмический фолбек), `internal/qbt` (поле `Rename` в `AddRequest`, + form-field `rename`). Возможен небольшой хелпер вывода имени (в `ingest` + или соседнем пакете). +- **Конфиг:** возможен бюджет попыток LLM (переиспользовать существующий + `llm.max_retries` либо отдельный параметр) — уточняется в design. +- **Внешние системы:** дополнительный вызов LLM на каждую новую загрузку + (расход токенов); деградирует штатно — при недоступности LLM работает + алгоритмический фолбек. +- **Инварианты:** не затрагиваются. Имя влияет только на отображение в + qBittorrent; пути на диске берутся из qBit API, источник не трогаем. diff --git a/openspec/changes/add-qbt-display-name/specs/ingest/spec.md b/openspec/changes/add-qbt-display-name/specs/ingest/spec.md new file mode 100644 index 0000000..5a8cc2b --- /dev/null +++ b/openspec/changes/add-qbt-display-name/specs/ingest/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: Отображаемое имя торрента из контекста + +При добавлении загрузки в qBittorrent система SHALL выводить из контекста +загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent +(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не +показывалась безликим `dn` magnet-ссылки. + +Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и +год; для сериала — номер сезона, если он определён), а не куском сырого +контекста. Имя SHALL очищаться от управляющих символов и переводов строк и +SHALL обрезаться по ограничению длины. + +Вывод имени SHALL выполняться синхронно перед отдачей источника в +qBittorrent (параметр `rename` действует только в момент добавления). + +Отображаемое имя SHALL влиять только на отображение в qBittorrent и SHALL +NOT влиять на пути файлов на диске, распознавание или раскладку — реальные +пути система по-прежнему читает из qBit API. + +#### Scenario: Имя из контекста передаётся в qBittorrent + +- **WHEN** загрузку добавляют с непустым контекстом, из которого удалось + получить имя +- **THEN** система передаёт это имя в qBittorrent в параметре `rename` +- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)», + где режиссёр и год опциональны + +#### Scenario: Контекст пуст или имя не получено + +- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени +- **THEN** система добавляет загрузку без параметра `rename` +- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента) + +### 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` diff --git a/openspec/changes/add-qbt-display-name/tasks.md b/openspec/changes/add-qbt-display-name/tasks.md new file mode 100644 index 0000000..0141e35 --- /dev/null +++ b/openspec/changes/add-qbt-display-name/tasks.md @@ -0,0 +1,56 @@ +## 1. qBittorrent: проброс имени + +- [ ] 1.1 Добавить поле `Rename string` в `qbt.AddRequest` +- [ ] 1.2 В `Client.Add` писать form-field `rename` при непустом `Rename` +- [ ] 1.3 Тест: при заданном `Rename` form-data содержит `rename`, при + пустом — поля нет + +## 2. Вывод имени: структура и рендер + +- [ ] 2.1 Описать структуру извлечённого имени (type, title, + original_title, year, director, season, is_russian) +- [ ] 2.2 Реализовать чистую функцию рендера структуры в короткий ярлык: + `Title (Director, Year)` с опциональными режиссёром и годом (скобка + опускается, если обоих нет), для сериала — суффикс `. Сезон N`; + очистка управляющих символов/переводов строк и обрезка по длине +- [ ] 2.3 Тесты рендера: фильм с режиссёром+годом / только год / только + режиссёр / без обоих, сериал с сезоном/без, обрезка длины, очистка + +## 3. Вывод имени: LLM + +- [ ] 3.1 Узкий промпт извлечения имени (RU-название для российского + контента, иначе EN/оригинал) + описание JSON-схемы ответа +- [ ] 3.2 Парсинг и валидация ответа (валидный JSON, непустой `title`); + бюджет попыток — `[llm].max_retries` (переиспользуем существующий) +- [ ] 3.3 Тесты на фикстурах (без сети): успешный разбор, выбор языка + названия, исчерпание попыток + +## 4. Вывод имени: алгоритмический фолбек + +- [ ] 4.1 Реализовать фолбек без сети: первая содержательная строка + контекста (без ссылок/команд/UI-мусора), отсечение тех. + характеристик, очистка и обрезка +- [ ] 4.2 Вынести/переиспользовать логику чистки строк (сейчас в + `tgbot.cleanContext`), чтобы не дублировать +- [ ] 4.3 Тесты фолбека: заголовок релиза, пустой/бесполезный контекст → "" + +## 5. Интеграция в ingest + +- [ ] 5.1 Ввести узкий интерфейс/функцию вывода имени (`DeriveName`), + реализованную поверх LLM + фолбек; зависимость опциональна (нет LLM → + только фолбек) +- [ ] 5.2 В `Ingest()` синхронно выводить имя из `req.Context` (подсказка — + `magnet.DisplayName`) перед `qbt.Add`, класть в `AddRequest.Rename` +- [ ] 5.3 Graceful-деградация: ошибка/таймаут вывода имени логируется и не + прерывает приём (пустое имя → без `rename`). Бюджет попыток и таймаут — + общие `[llm].max_retries` / `[llm].timeout`, новых параметров не вводим +- [ ] 5.4 Прокинуть зависимость (LLM-провайдер) в сборке сервиса + (`cmd/jellybit`) + +## 6. Проверка + +- [ ] 6.1 `task test` и `task lint` зелёные +- [ ] 6.2 Ручная проверка на реальном qBittorrent: имя видно в списке; + подтвердить поведение `rename` для многофайловой раздачи +- [ ] 6.3 Обновить `docs/specs` (architecture/ingest) и отметить todo + «Название из контекста» как реализованную при архивации change