Голый magnet без dn/контекста заставлял namer звать LLM на пустом входе, и модель галлюцинировала мусорное имя («Unknown»), которое писалось и в display_name, и в rename qBittorrent, а заодно ломало UI-фолбэк на распознанное название. Верное каноническое имя, вычисляемое позже при распознавании, никуда не переливалось. - naming: гард пустого входа в DeriveName (нет контекста и подсказки → "" без вызова LLM) + детерминированный форматтер FormatTitleYear. - qbt: операция RenameTorrent (переименование существующей раздачи). - store: SetDisplayName — обновление имени постфактум без гарда состояния. - worker: refreshDisplayNameLocked/RefreshDisplayName — перелив канонического имени (эффективный план) в display_name + best-effort rename раздачи по реальному t.Hash; авто-триггер при подтверждении матча (choose/manual add). - web-ui: кнопка «Обновить имя» на странице загрузки (htmx-своп заголовка, деградация без JS), видимая при наличии распознавания (вкл. done/orphaned). Спека: дельты ingest/review/web-ui влиты в openspec/specs; change refresh-display-name заархивирован. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
14 KiB
Markdown
183 lines
14 KiB
Markdown
## 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.
|