## Context Отображаемое имя (`download.display_name`) выводится **один раз** — на шаге добавления пойманной загрузки в qBittorrent (`worker.processCatched` → `namer.DeriveName` → `PromoteCatched`). Источник имени на этом шаге — только пользовательский контекст и подсказка из полей источника (`dn` magnet / имя `.torrent`). Дерева файлов ещё нет (magnet не разрезолвлен), поэтому для голого magnet без контекста выводить имя не из чего. Наблюдавшийся дефект (`download_id=01kx5sk1q2vdm7xgyznrz5kheg`): при пустом входе `DeriveName` всё равно вызывает LLM, и модель возвращает мусорное непустое имя («Unknown»). Оно пишется в `display_name` и в `rename` qBittorrent и блокирует UI-фолбэк `downloadTitle` (тот отдаёт непустой `display_name` вместо распознанного названия). Между тем к моменту ревью каноническое имя **уже вычислено и сохранено**: `recognition.title`/`year`, а при выборе кандидата — пины `title`/`year` через `SetOverride` (`chooseCandidateLocked`) и `metadata_candidate.title`/`year`. Ничто не переливает его обратно. Ограничения: инвариант «источник неприкосновенен», отображаемое имя — исключительно косметика (не влияет на пути/распознавание/раскладку); переименовать можно только свою раздачу (адресация по infohash-владению). ## Goals / Non-Goals **Goals:** - Убрать корень «Unknown»: не выводить имя из пустого входа (не звать LLM, когда нет ни контекста, ни подсказки). - Дать способ обновить `display_name` и имя раздачи в qBittorrent на уже вычисленное каноническое имя — авто (при подтверждённом матче) и вручную (кнопка), **без нового вызова LLM**. - Добавить операцию переименования существующей раздачи в qBittorrent. **Non-Goals:** - Переизобретать вывод имени: новый LLM-вызов по контексту не делаем (контекст тот же скудный, что дал «Unknown»). - Локализация/выбор языка названия — берём каноническое как есть (то же, что использует раскладка). - Суффикс сезона в имени сериала — распознавание не несёт скалярного `season` (сезоны — per-file); ограничиваемся `Title (Year)`. - Изменения схемы БД — используем существующий `download.display_name`. ## Decisions ### D1. Источник имени — эффективное распознанное название, не новый LLM Обновлённое имя берём из **эффективного** распознанного названия: пины `title`/`year` (если матч подтверждён выбором кандидата/ручным пином) поверх `recognition.title`/`year`. Это ровно то, что использует раскладка, — значит имя согласовано с тем, куда лягут файлы. - _Альтернатива A: заново звать `namer.DeriveName` по контексту._ Отклонено: контекст тот же скудный, что уже дал «Unknown»; распознавание видело дерево файлов и точнее. - _Альтернатива B: `rec_title` как есть._ `recognition.title` без года — беднее; формат `Title (Year)` информативнее и совпадает с ярлыком add-шага. ### D2. Детерминированный формат имени Формат — `Title (Year)` (год опционален; при отсутствии — просто `Title`), очистка от управляющих символов и обрезка по длине переиспользуют существующую логику `naming` (`sanitize`/`truncate`). Без сети, без LLM. Форматтер `Title (Year)` живёт в пакете `naming` (там же, где `sanitize`/ `truncate` — их придётся экспортировать) и вызывается и авто-триггером, и ручным эндпоинтом. Он **не** унифицируется с add-шаговым `render()`: тот даёт иной формат `Title (Director, Year)` + `. Сезон N`; общими остаются только `sanitize`/ `truncate`, но не сам форматтер (у refresh нет режиссёра/скалярного сезона). ### D3. Переименование раздачи в qBittorrent — новая best-effort операция Добавляем `qbt.Client.RenameTorrent(ctx, hash, name)` → `POST /api/v2/torrents/rename` (form `hash`, `name`). Адресация: qBittorrent индексирует раздачу собственным `hash`, который для гибридных/v2-only раздач может не совпадать с нашим `PrimaryInfohash()` (первый из сохранённых v1/v2). Поэтому helper резолвит раздачу через существующий `torrentByInfohash` (`review.go`) по любому из наших хешей и передаёт в `RenameTorrent` настоящий `t.Hash`. Побочно это бесплатно закрывает случай «раздача удалена» — резолв не находит `t`, rename не вызывается (best-effort no-op). Переименование в qBittorrent — **best-effort**: сбой (раздача уже удалена, qBit недоступен) НЕ проваливает обновление имени. `display_name` в нашей БД обновляется в любом случае; ошибку qBit логируем `WARN` (внешний вызов — `ext.service=qbittorrent`). Обоснование: имя косметическое, недоступность qBit не должна блокировать команду ревью или кнопку. ### D4. Точки входа: авто при подтверждённом матче + ручная кнопка Помощник расщеплён по контракту блокировки (конвенция суффикса `*Locked` в worker): вся логика — в `refreshDisplayNameLocked(ctx, id)`, **вызывается под `w.mu`**; публичная обёртка `RefreshDisplayName(ctx, id)` берёт `w.mu` сама. `refreshDisplayNameLocked`: 1. читает загрузку и текущее распознавание с учётом пинов; 2. формирует имя (D2); пустое имя → ничего не делаем (no-op); 3. пишет `display_name` (новый store-метод, D5); 4. резолвит раздачу через `torrentByInfohash` и best-effort переименовывает её в qBittorrent (D3). - **Авто:** `chooseCandidateLocked` (уже под `w.mu`) вызывает `refreshDisplayNameLocked` **после** успешного `SetCandidateChosen`. `chooseCandidateLocked` — общий путь и для выбора кандидата из списка (`ChooseCandidate`), и для ручного добавления источника (`AddManualSource`); оба — подтверждение матча, оба получают авто-refresh (согласуется с инвариантом «авто-действие только при подтверждённом матче»). Наблюдавшийся кейс (`matched=false` → ручной выбор кандидата) закрывается именно этим. - **Ручной:** HTTP-эндпоинт `POST /ui/downloads/{id}/refresh-name` вызывает обёртку `RefreshDisplayName` (берёт `w.mu`) и возвращает htmx-партиал заголовка. Доступен, когда у загрузки есть распознавание (в т.ч. `done`/ `orphaned`, а не только reviewable-состояния — см. D8). Замечание по блокировке: авто-путь делает сетевой резолв `Torrents`/`RenameTorrent` под `w.mu` (как уже делают `Apply`/`Delete`), т.е. на время rename команды ревью заблокированы. Приемлемо: rename — один короткий вызов; альтернатива (вынести из-под mu) усложнила бы ре-валидацию state и не стоит того для косметики. ### D5. Store-метод обновления имени пост-фактум Текущий `PromoteCatched` пишет `display_name` только на переходе `catched→downloading`. Нужен отдельный метод `SetDisplayName(ctx, id, name)` (UPDATE `display_name`, `updated_at`), не завязанный на состояние: обновление имени валидно в `review`/`done` и не двигает FSM. ### D6. Политика перезаписи - **Ручная кнопка:** всегда перезаписывает (явное действие пользователя). - **Авто при подтверждённом матче:** перезаписывает, когда выведенное имя непусто. Подтверждённый матч авторитетнее add-догадки; отдельного пользовательского редактирования `display_name` в системе нет, затирать нечего. ### D7. Гард пустого входа в naming (корень «Unknown») `DeriveName`: если `strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint) == ""` — сразу вернуть `""`, не вызывая ни LLM, ни фолбек. Приём добавляет загрузку без `rename`, `display_name` пуст, UI берёт заголовок из фолбека (распознанное имя или усечённый источник). Позже refresh донесёт каноническое имя. ### D8. Видимость кнопки «Обновить имя» — по наличию распознавания Кнопка гейтится **наличием распознавания** (`rd.Recognition != nil`/наличие плана), а НЕ полем `Reviewable`. Иначе на `done`/`orphaned` (распознавание уже есть, но состояние не reviewable) кнопка пропала бы — а именно там она и нужна, чтобы переименовать раздачу постфактум. Так же это оставляет запас на будущее: при включённых метабазах авто-раскладка (`finishRecognition`, Auto без review) минует `chooseCandidateLocked`, и единственной точкой обновления имени для голого magnet на `done` останется кнопка. ## Risks / Trade-offs - **Переименование раздачи в qBittorrent удивит пользователя, следящего за клиентом.** → Это и есть цель (осмысленное имя вместо «Unknown»); срабатывает только при подтверждённом матче или явной кнопке. - **Каноническое имя на языке оригинала может отличаться от ожидаемого.** → Non-goal (D1/Non-Goals); берём то же имя, что и раскладка, — консистентность важнее локализации. - **Гонки: раздачу удалили между чтением и rename.** → Best-effort (D3): rename не проваливает операцию, `display_name` уже обновлён. - **Затирание add-имени слегка иным форматом.** → Приемлемо (D6): подтверждённый матч авторитетнее; формат совпадает с add-ярлыком. ## Migration Plan Обратная совместимость полная: схема БД не меняется, старые загрузки продолжают работать. Развёртывание — обычный деплой бинаря. Откат — откат бинаря; данные не мигрируют. Уже существующие «Unknown»-загрузки чинятся ручной кнопкой или повторным подтверждением кандидата. ## Open Questions Разрешено на ревью дизайна (чекпоинт №1): - **Авто-триггер держим узким:** только `chooseCandidateLocked` (выбор кандидата + ручное добавление источника) + ручная кнопка. `SetProviderID` и `Apply` авто-refresh НЕ получают. Причины: `SetProviderID` пинит пустые `title`/`year` (`sourcePins(...,"",0)`) — эффективное имя всё равно падает на `recognition.title`, «подтверждённости» меньше, а кнопка это закрывает; `Apply` относится к `file-layout`, вшивать туда косметику — размывать границы capability и дёргать rename на каждом повторном apply.