Имя: восстановление display_name после распознавания + гард пустого входа
Голый 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>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-10
|
||||
@@ -0,0 +1,182 @@
|
||||
## 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.
|
||||
@@ -0,0 +1,67 @@
|
||||
## Why
|
||||
|
||||
При бедном первичном приёме (голый magnet без `dn` и без контекста) вывод имени
|
||||
на шаге добавления зовёт LLM на пустом входе, и модель галлюцинирует мусорное имя
|
||||
(наблюдалось «Unknown»). Это имя пишется и в `download.display_name`, и в `rename`
|
||||
qBittorrent, и вдобавок ломает UI-фолбэк на распознанное название (`downloadTitle`
|
||||
отдаёт непустой `display_name`). Позже распознавание по дереву файлов даёт верное
|
||||
имя (в наблюдавшемся случае — «Harold and the Purple Crayon», 2024), но это имя
|
||||
никуда не переливается: и карточка загрузки, и раздача в qBittorrent остаются с
|
||||
«Unknown».
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Гард пустого входа при выводе имени.** Если и контекст, и подсказка (`dn`
|
||||
magnet / имя `.torrent`) пусты, система не зовёт LLM и считает имя не
|
||||
выведенным (`display_name` остаётся пустым, `rename` не задаётся). Убирает
|
||||
корень «Unknown» — приём не присваивает имя из пустоты.
|
||||
- **Обновление имени по подтверждённому распознаванию.** Появляется поведение
|
||||
«перелить» уже вычисленное каноническое имя (из распознавания / выбранного
|
||||
кандидата метабазы) в `download.display_name` и в имя раздачи qBittorrent.
|
||||
Детерминированный формат `Title (Year)` (для сериала — по аналогии с текущим
|
||||
выводом имени), **без нового вызова LLM** — источник уже сохранён
|
||||
(`recognition.title/year`, `metadata_candidate.title/year`).
|
||||
- **Авто:** при подтверждении матча в ревью (выбор кандидата) — согласуется с
|
||||
инвариантом «авто-действие только при подтверждённом матче».
|
||||
- **Ручной:** кнопка «Обновить имя» на карточке загрузки.
|
||||
- **Переименование существующей раздачи в qBittorrent.** Новая операция клиента
|
||||
qBittorrent `rename` для уже добавленной раздачи (сейчас `rename` действует
|
||||
только при `add`). Адресация — по infohash своей раздачи.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `ingest`: требование «Вывод имени через LLM» ужесточается гардом пустого входа
|
||||
(нет ни контекста, ни подсказки → LLM не вызывается, имя считается не
|
||||
выведенным). Добавляется требование «Обновление отображаемого имени по
|
||||
подтверждённому распознаванию» (источник имени, детерминированный формат,
|
||||
запись `display_name`, переименование раздачи qBittorrent; точки входа — авто
|
||||
и ручная; политика перезаписи).
|
||||
- `review`: подтверждение матча (команда выбора/применения кандидата) SHALL
|
||||
запускать обновление отображаемого имени по подтверждённому распознаванию.
|
||||
- `web-ui`: на карточке/странице загрузки появляется htmx-действие «Обновить
|
||||
имя», обновляющее заголовок на месте.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Код:**
|
||||
- `internal/naming/naming.go` — гард пустого входа в `DeriveName`.
|
||||
- `internal/qbt` — новая операция `RenameTorrent(ctx, hash, name)` (`POST
|
||||
/api/v2/torrents/rename`).
|
||||
- `internal/store` — метод обновления `download.display_name` пост-фактум.
|
||||
- `internal/worker` — перелив имени при подтверждении кандидата в ревью.
|
||||
- `internal/httpapi` — эндпоинт ручного обновления имени + htmx-партиал
|
||||
заголовка; кнопка в шаблоне карточки/страницы загрузки.
|
||||
- **Инварианты безопасности данных:** изменение чисто косметическое
|
||||
(`display_name` + ярлык раздачи в qBittorrent). Пути на диске
|
||||
(`paths.movies`/`series`), распознавание и раскладка НЕ затрагиваются; rename
|
||||
адресуется по infohash своей раздачи.
|
||||
- **БД:** структура не меняется (используется существующий столбец
|
||||
`download.display_name`); миграция не требуется.
|
||||
- **Внешние вызовы:** дополнительный вызов qBittorrent `rename` при обновлении
|
||||
имени (логируется как `ext.service=qbittorrent`). Новых LLM-вызовов нет.
|
||||
@@ -0,0 +1,100 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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 берётся из фолбека)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Обновление отображаемого имени по распознаванию
|
||||
|
||||
Система SHALL уметь обновлять отображаемое имя загрузки после того, как
|
||||
распознавание дало каноническое название, — переливая уже вычисленное имя (без
|
||||
нового вызова LLM) в `download.display_name` и в имя раздачи qBittorrent.
|
||||
|
||||
Источником имени SHALL быть **эффективное** распознанное название: пины
|
||||
`title`/`year` (если матч подтверждён), иначе `recognition.title`/`year` — то
|
||||
же название, что использует раскладка. Формат SHALL быть коротким детерминированным
|
||||
ярлыком `Title (Year)` (год опционален), с той же очисткой от управляющих
|
||||
символов и обрезкой по длине, что и вывод имени на шаге добавления. Пустой
|
||||
источник (нет распознавания или пустое название) 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` устанавливается в `Title (Year)`
|
||||
- **AND** раздача в qBittorrent переименовывается в то же имя (по infohash своей
|
||||
раздачи)
|
||||
|
||||
#### Scenario: qBittorrent недоступен — имя у загрузки всё равно обновлено
|
||||
|
||||
- **GIVEN** обновление отображаемого имени с выведенным непустым именем
|
||||
- **WHEN** переименование раздачи в qBittorrent завершается ошибкой (недоступен
|
||||
или раздача удалена)
|
||||
- **THEN** `download.display_name` всё равно обновлён
|
||||
- **AND** ошибка внешнего вызова qBittorrent логируется, операция не проваливается
|
||||
|
||||
#### Scenario: Нет распознавания — обновление ничего не делает
|
||||
|
||||
- **GIVEN** загрузка без распознанного названия (пустой источник имени)
|
||||
- **WHEN** запускается обновление отображаемого имени
|
||||
- **THEN** ни `download.display_name`, ни имя раздачи не меняются (no-op)
|
||||
@@ -0,0 +1,40 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Подтверждение матча обновляет отображаемое имя
|
||||
|
||||
Система SHALL при подтверждении матча в ревью запускать обновление отображаемого
|
||||
имени загрузки по подтверждённому распознаванию (см. capability `ingest`):
|
||||
переливать каноническое название (`Title (Year)`) в `download.display_name` и в
|
||||
имя раздачи qBittorrent, без нового вызова LLM. Подтверждением матча SHALL
|
||||
считаться как выбор кандидата из списка совпадений, так и ручное добавление
|
||||
источника по id/URL (оба закрепляют провайдера и каноническое название).
|
||||
|
||||
Обновление SHALL выполняться после успешного закрепления выбора кандидата и
|
||||
SHALL быть best-effort по отношению к qBittorrent: недоступность клиента SHALL
|
||||
NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие
|
||||
только при подтверждённом матче».
|
||||
|
||||
#### Scenario: Выбор кандидата обновляет имя
|
||||
|
||||
- **GIVEN** загрузка в ревью с пустым или неинформативным `display_name`
|
||||
(например, «Unknown») и списком кандидатов
|
||||
- **WHEN** пользователь выбирает кандидата, подтверждая матч
|
||||
- **THEN** выбор кандидата закрепляется как и прежде
|
||||
- **AND** `download.display_name` обновляется каноническим именем кандидата
|
||||
`Title (Year)`
|
||||
- **AND** раздача в qBittorrent переименовывается в то же имя
|
||||
|
||||
#### Scenario: Ручное добавление источника обновляет имя
|
||||
|
||||
- **GIVEN** загрузка в ревью без совпадений в списке
|
||||
- **WHEN** пользователь вручную добавляет источник по id/URL, подтверждая матч
|
||||
- **THEN** источник закрепляется как и прежде
|
||||
- **AND** `download.display_name` и имя раздачи в qBittorrent обновляются
|
||||
каноническим названием подтверждённого источника
|
||||
|
||||
#### Scenario: Недоступность qBittorrent не ломает выбор кандидата
|
||||
|
||||
- **GIVEN** выбор кандидата в ревью
|
||||
- **WHEN** переименование раздачи в qBittorrent завершается ошибкой
|
||||
- **THEN** выбор кандидата и обновление `download.display_name` выполнены
|
||||
- **AND** команда ревью не возвращает ошибку
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Действие «Обновить имя» на странице загрузки
|
||||
|
||||
Когда у загрузки есть распознавание, страница загрузки SHALL предлагать действие
|
||||
«Обновить имя», запускающее обновление отображаемого имени по распознаванию (см.
|
||||
capability `ingest`). Видимость действия SHALL определяться наличием распознавания
|
||||
(а не состоянием ревью): в частности, действие SHALL быть доступно и на терминальных
|
||||
состояниях с распознаванием (`done`/`orphaned`), где раздачу нужно переименовать
|
||||
постфактум. Действие SHALL обновлять заголовок на месте по htmx-паттерну
|
||||
(фрагмент заголовка перерисовывается ответом), без перезагрузки страницы, и SHALL
|
||||
деградировать без JS (обычная форма-POST с переходом на страницу загрузки).
|
||||
|
||||
Действие SHALL быть идемпотентным по эффекту: повторный запуск на неизменном
|
||||
распознавании даёт то же имя. Отсутствие распознавания (пустой источник имени)
|
||||
SHALL приводить к отсутствию видимых изменений.
|
||||
|
||||
#### Scenario: Ручное обновление имени перерисовывает заголовок
|
||||
|
||||
- **GIVEN** страница загрузки с распознанным каноническим названием и заголовком
|
||||
«Unknown»
|
||||
- **WHEN** пользователь запускает действие «Обновить имя»
|
||||
- **THEN** заголовок на странице перерисовывается каноническим именем `Title (Year)`
|
||||
на месте (htmx), без полной перезагрузки
|
||||
|
||||
#### Scenario: Деградация без JS
|
||||
|
||||
- **GIVEN** клиент без htmx/JS
|
||||
- **WHEN** пользователь отправляет действие «Обновить имя» обычной формой
|
||||
- **THEN** сервер выполняет обновление и отвечает страницей загрузки с новым
|
||||
заголовком
|
||||
@@ -0,0 +1,40 @@
|
||||
## 1. Гард пустого входа в naming (корень «Unknown»)
|
||||
|
||||
- [x] 1.1 В `internal/naming/naming.go` `DeriveName`: если `strings.TrimSpace(contextText)` и `strings.TrimSpace(hint)` оба пусты — вернуть `""` до вызова LLM и фолбека
|
||||
- [x] 1.2 Тест: пустой контекст + пустой hint → `""`, провайдер LLM не вызван (фейковый provider фиксирует отсутствие вызова)
|
||||
- [x] 1.3 Тест регрессии: непустой hint (или контекст) при пустом другом — прежнее поведение сохранено
|
||||
|
||||
## 2. Операция переименования раздачи в qBittorrent
|
||||
|
||||
- [x] 2.1 В `internal/qbt` добавить `Client.RenameTorrent(ctx, hash, name)` → `POST /api/v2/torrents/rename` (form `hash`, `name`), логирование внешнего вызова как `ext.service=qbittorrent`, `ext.operation=torrents/rename`
|
||||
- [x] 2.2 Тест клиента qBittorrent на форму запроса и разбор ответа/ошибки (по образцу существующих тестов `Add`/`Delete`)
|
||||
|
||||
## 3. Store: обновление display_name пост-фактум
|
||||
|
||||
- [x] 3.1 В `internal/store` добавить `SetDisplayName(ctx, id, name)` — UPDATE `display_name`, `updated_at`, без гарда состояния (не двигает FSM)
|
||||
- [x] 3.2 Тест store: имя обновляется в `review`/`done`; `updated_at` продвинут
|
||||
|
||||
## 4. Форматтер и помощник обновления имени
|
||||
|
||||
- [x] 4.1 В пакете `naming`: экспортировать `sanitize`/`truncate` (или добавить публичную обёртку) и добавить детерминированный форматтер `FormatTitleYear(title string, year int) string` (`Title (Year)`, год опционален; очистка/обрезка) — без LLM. НЕ унифицировать с `render()` (у refresh нет режиссёра/сезона)
|
||||
- [x] 4.2 Добавить в интерфейсы worker методы `RenameTorrent` (QBittorrent) и `SetDisplayName` (Store)
|
||||
- [x] 4.3 Реализовать `refreshDisplayNameLocked(ctx, id)` (вызывается ПОД `w.mu`): прочитать загрузку и эффективное распознанное название (пины `title`/`year` поверх `recognition.title`/`year`), собрать имя через `naming.FormatTitleYear`, при пустом — no-op; записать `display_name` через `SetDisplayName`; затем резолвить раздачу через `torrentByInfohash` и best-effort `RenameTorrent` по её реальному `t.Hash` (не по сырому primary infohash) — сбой/отсутствие раздачи логировать `WARN`, операцию не проваливать
|
||||
- [x] 4.4 Публичная обёртка `RefreshDisplayName(ctx, id)`: берёт `w.mu` и вызывает `refreshDisplayNameLocked` (для внешнего вызова из httpapi)
|
||||
- [x] 4.5 Тесты worker: перелив имени в `display_name` + вызов `RenameTorrent` с реальным `t.Hash`; недоступность/отсутствие раздачи qBittorrent → `display_name` обновлён, ошибки нет; отсутствие распознавания → no-op
|
||||
|
||||
## 5. Авто-триггер при подтверждённом матче (review)
|
||||
|
||||
- [x] 5.1 В `chooseCandidateLocked` после успешного `SetCandidateChosen` вызвать `refreshDisplayNameLocked(ctx, id)` (общий путь покрывает и `ChooseCandidate`, и `AddManualSource`)
|
||||
- [x] 5.2 Тесты: выбор кандидата (`ChooseCandidate`) и ручное добавление источника (`AddManualSource`) обновляют `display_name` каноническим именем и переименовывают раздачу; ошибка qBittorrent не ломает команду
|
||||
|
||||
## 6. Ручное действие «Обновить имя» (web-ui)
|
||||
|
||||
- [x] 6.1 Эндпоинт `POST /ui/downloads/{id}/refresh-name` в `internal/httpapi`: вызвать `RefreshDisplayName` (обёртка с `w.mu`), вернуть htmx-партиал заголовка (ветвление по `isHTMX`), деградация без JS — редирект/страница загрузки с новым заголовком
|
||||
- [x] 6.2 Кнопка «Обновить имя» в шаблоне страницы загрузки, видимая при наличии распознавания (`rd.Recognition != nil`/наличие плана), включая `done`/`orphaned` — НЕ гейтить по `Reviewable`; htmx-обновление фрагмента заголовка на месте
|
||||
- [x] 6.3 Тест httpapi: POST на загрузку с распознаванием возвращает партиал с новым заголовком; идемпотентность (повтор даёт то же имя); загрузка без распознавания → без изменений; кнопка видна на `done`
|
||||
|
||||
## 7. Финализация
|
||||
|
||||
- [x] 7.1 `task test` и `task lint` зелёные
|
||||
- [x] 7.2 `openspec validate refresh-display-name --strict` проходит
|
||||
- [x] 7.3 Ревью кода (чекпоинт перед archive): сверка со спеками + инварианты/конвенции
|
||||
Reference in New Issue
Block a user